chore: import upstream snapshot with attribution
Validate YAML Workflows / Validate YAML Configuration Files (push) Waiting to run

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
View File
+218
View File
@@ -0,0 +1,218 @@
"""Cycle detection and management for workflow graphs."""
from typing import Dict, List, Set, Optional, Any
from dataclasses import dataclass, field
from entity.configs import Node
@dataclass
class CycleInfo:
"""Information about a detected cycle in the workflow graph."""
cycle_id: str
nodes: Set[str] # Node IDs in the cycle
entry_nodes: Set[str] # Nodes that can enter the cycle (kept for compatibility)
exit_edges: List[Dict[str, Any]] # Edges that can exit the cycle
iteration_count: int = 0
max_iterations: Optional[int] = None # Safety limit
is_active: bool = False
execution_state: Dict[str, Any] = field(default_factory=dict)
# New fields for refactored cycle execution
initial_node: Optional[str] = None # The unique initial node when first entering the cycle
configured_entry_node: Optional[str] = None # User-configured entry node (if any)
max_iterations_default: int = 100 # Default maximum iterations if max_iterations is None
def add_node(self, node_id: str) -> None:
"""Add a node to the cycle."""
self.nodes.add(node_id)
def add_entry_node(self, node_id: str) -> None:
"""Add an entry node to the cycle."""
self.entry_nodes.add(node_id)
def add_exit_edge(self, edge_config: Dict[str, Any]) -> None:
"""Add an exit edge configuration."""
self.exit_edges.append(edge_config)
def increment_iteration(self) -> None:
"""Increment the iteration counter."""
self.iteration_count += 1
def is_within_iteration_limit(self) -> bool:
"""Check if the cycle should continue executing."""
max_iter = self.max_iterations if self.max_iterations is not None else self.max_iterations_default
return self.iteration_count < max_iter
def get_max_iterations(self) -> int:
"""Get the effective maximum iterations."""
return self.max_iterations if self.max_iterations is not None else self.max_iterations_default
def reset_iteration_count(self) -> None:
"""Reset the iteration counter."""
self.iteration_count = 0
def is_node_in_cycle(self, node_id: str) -> bool:
"""Check if a node is part of this cycle."""
return node_id in self.nodes
def is_entry_node(self, node_id: str) -> bool:
"""Check if a node is an entry node for this cycle."""
return node_id in self.entry_nodes
class CycleDetector:
"""Detects cycles in workflow graphs using Tarjan's algorithm."""
def __init__(self):
self.index_counter = 0
self.index: Dict[str, int] = {}
self.low_link: Dict[str, int] = {}
self.stack: List[str] = []
self.on_stack: Set[str] = set()
self.cycles: List[Set[str]] = []
def detect_cycles(self, nodes: Dict[str, Node]) -> List[Set[str]]:
"""Detect all cycles in the graph using Tarjan's strongly connected components' algorithm."""
self.cycles.clear()
self.index_counter = 0
self.index.clear()
self.low_link.clear()
self.stack.clear()
self.on_stack.clear()
for node_id in nodes:
if node_id not in self.index:
self._strong_connect(node_id, nodes)
# return [cycle for cycle in self.cycles if len(cycle) > 1 or self._has_self_loop(next(iter(cycle)), nodes)]
return self.cycles
def _has_self_loop(self, node_id: str, nodes: Dict[str, Node]) -> bool:
"""Check if a node has a self-loop."""
node = nodes.get(node_id)
if not node:
return False
return any(edge_link.target.id == node_id for edge_link in node.iter_outgoing_edges())
def _strong_connect(self, node_id: str, nodes: Dict[str, Node]) -> None:
"""Recursive part of Tarjan's algorithm."""
self.index[node_id] = self.index_counter
self.low_link[node_id] = self.index_counter
self.index_counter += 1
self.stack.append(node_id)
self.on_stack.add(node_id)
node = nodes.get(node_id)
if not node:
return
# Consider successors of node
for edge_link in node.iter_outgoing_edges():
successor_id = edge_link.target.id
if successor_id not in self.index:
self._strong_connect(successor_id, nodes)
self.low_link[node_id] = min(self.low_link[node_id], self.low_link[successor_id])
elif successor_id in self.on_stack:
self.low_link[node_id] = min(self.low_link[node_id], self.index[successor_id])
# If node is a root node, pop the stack and generate an SCC
if self.low_link[node_id] == self.index[node_id]:
cycle = set()
while True:
w = self.stack.pop()
self.on_stack.remove(w)
cycle.add(w)
if w == node_id:
break
if len(cycle) > 1 or self._has_self_loop(node_id, nodes):
self.cycles.append(cycle)
class CycleManager:
"""Manages execution of cycles in the workflow graph."""
def __init__(self):
self.cycles: Dict[str, CycleInfo] = {}
self.node_to_cycle: Dict[str, str] = {} # Maps node ID to cycle ID
self.active_cycles: Set[str] = set()
def initialize_cycles(self, cycles: List[Set[str]], nodes: Dict[str, Node]) -> None:
"""Initialize cycle information from detected cycles."""
self.cycles.clear()
self.node_to_cycle.clear()
for i, cycle_nodes in enumerate(cycles):
cycle_id = f"cycle_{i}_{cycle_nodes}"
cycle_info = CycleInfo(
cycle_id=cycle_id,
nodes=set(cycle_nodes),
entry_nodes=set(),
exit_edges=[]
)
# Find entry nodes and exit edges
self._analyze_cycle_structure(cycle_info, nodes)
self.cycles[cycle_id] = cycle_info
# Map nodes to their cycle
for node_id in cycle_nodes:
self.node_to_cycle[node_id] = cycle_id
def _analyze_cycle_structure(self, cycle: CycleInfo, nodes: Dict[str, Node]) -> None:
"""Analyze cycle structure to find entry nodes and exit edges."""
cycle_nodes = cycle.nodes
# Find entry nodes (nodes with predecessors outside the cycle)
def judge_entry_predecessor(_predecessor: Node, _predecessor_id: str) -> bool:
if _predecessor_id in cycle_nodes:
return False
for _edge_link in _predecessor.iter_outgoing_edges():
if _edge_link.target.id == node_id:
if _edge_link.trigger:
return True
else:
return False
return False
for node_id in cycle_nodes:
node = nodes.get(node_id)
if not node:
continue
for predecessor in node.predecessors:
if judge_entry_predecessor(predecessor, predecessor.id):
cycle.add_entry_node(node_id)
break
# Find exit edges (edges from cycle nodes to nodes outside the cycle)
for node_id in cycle_nodes:
node = nodes.get(node_id)
if not node:
continue
for edge_link in node.iter_outgoing_edges():
if edge_link.target.id not in cycle_nodes and edge_link.trigger:
exit_edge = {
"from": node_id,
"to": edge_link.target.id,
"condition": edge_link.condition,
"trigger": edge_link.trigger,
"config": edge_link.config
}
cycle.add_exit_edge(exit_edge)
def activate_cycle(self, cycle_id: str) -> None:
"""Activate a cycle for execution."""
if cycle_id in self.cycles:
self.cycles[cycle_id].is_active = True
self.active_cycles.add(cycle_id)
def deactivate_cycle(self, cycle_id: str) -> None:
"""Deactivate a cycle."""
if cycle_id in self.cycles:
self.cycles[cycle_id].is_active = False
self.cycles[cycle_id].iteration_count = 0
self.active_cycles.discard(cycle_id)
View File
+614
View File
@@ -0,0 +1,614 @@
"""Cycle executor that runs workflow graphs containing loops."""
import copy
import threading
from typing import Dict, List, Callable, Any, Set, Optional
from entity.configs import Node, EdgeLink
from utils.log_manager import LogManager
from workflow.cycle_manager import CycleManager
from workflow.executor.parallel_executor import ParallelExecutor
from workflow.topology_builder import GraphTopologyBuilder
class CycleExecutor:
"""Execute workflow graphs that contain cycles.
Features:
- Scheduling is based on "super nodes"
- Parallel execution inside cycles
- Automatic detection of exit conditions
"""
def __init__(
self,
log_manager: LogManager,
nodes: Dict[str, Node],
cycle_execution_order: List[Dict[str, Any]],
cycle_manager: CycleManager,
execute_node_func: Callable[[Node], None],
):
"""Initialize the cycle executor.
Args:
log_manager: Logger instance
nodes: Mapping of node ids to nodes
cycle_execution_order: Super-node execution order with cycles
cycle_manager: Cycle manager coordinating iterations
execute_node_func: Callable that executes a single node
"""
self.log_manager = log_manager
self.nodes = nodes
self.cycle_execution_order = cycle_execution_order
self.cycle_manager = cycle_manager
self.execute_node_func = execute_node_func
self.parallel_executor = ParallelExecutor(log_manager, nodes)
def execute(self) -> None:
"""Run the workflow that contains cycles."""
self.log_manager.debug("Executing graph with cycles using super-node scheduler")
for layer_idx, layer_items in enumerate(self.cycle_execution_order):
self.log_manager.debug(f"Executing super-node layer {layer_idx} with {len(layer_items)} items")
self._execute_super_layer(layer_items)
def _execute_super_layer(self, layer_items: List[Dict[str, Any]]) -> None:
"""Execute a single super-node layer."""
self._execute_super_layer_parallel(layer_items)
def _execute_super_layer_parallel(self, layer_items: List[Dict[str, Any]]) -> None:
"""Execute a super-node layer in parallel."""
def item_desc_func(item: Dict[str, Any]) -> str:
if item["type"] == "cycle":
return f"cycle {item['cycle_id']}"
elif item["type"] == "node":
# New format
return f"node {item['node_id']}"
else:
# Old format: "layer"
return f"node {item['nodes'][0]}"
self.parallel_executor.execute_items_parallel(
layer_items,
self._execute_super_item,
item_desc_func
)
def _execute_super_item(self, item: Dict[str, Any]) -> None:
"""Execute a single super-node item (node or cycle)."""
if item["type"] == "layer":
# Old format: {"type": "layer", "nodes": [node_id]}
self._execute_single_node(item["nodes"][0])
elif item["type"] == "node":
# New format from GraphTopologyBuilder: {"type": "node", "node_id": "..."}
self._execute_single_node(item["node_id"])
elif item["type"] == "cycle":
self._execute_cycle(item)
def _execute_single_node(self, node_id: str) -> None:
"""Execute a non-cycle node."""
self.log_manager.debug(f"Executing non-cycle node: {node_id}")
node = self.nodes[node_id]
if node.is_triggered():
self.execute_node_func(node)
else:
self.log_manager.warning(f"Node {node_id} is not triggered, skipping execution")
def _execute_cycle(self, cycle_info: Dict[str, Any]) -> None:
"""Execute a cycle using the multi-iteration logic."""
cycle_id = cycle_info["cycle_id"]
nodes = cycle_info["nodes"]
self.log_manager.debug(f"Executing cycle {cycle_id} with nodes: {nodes}")
# Step 2: Validate cycle entry uniqueness
try:
initial_node_id = self._validate_cycle_entry(cycle_id, nodes)
except ValueError as e:
self.log_manager.error(str(e))
raise
if initial_node_id is None:
self.log_manager.debug(
f"Cycle {cycle_id} has no triggered entry node in this pass; skipping execution"
)
return
# Store initial node in cycle_manager
self.cycle_manager.cycles[cycle_id].initial_node = initial_node_id
self.log_manager.debug(f"Cycle {cycle_id} initial node: {initial_node_id}")
# Activate cycle
self.cycle_manager.activate_cycle(cycle_id)
# Step 4: Execute cycle with iterations
self._execute_cycle_with_iterations(
cycle_id,
nodes,
initial_node_id,
max_iterations=self.cycle_manager.cycles[cycle_id].get_max_iterations()
)
# Cleanup
self.cycle_manager.deactivate_cycle(cycle_id)
self.log_manager.debug(f"Cycle {cycle_id} completed")
# ==================== New Methods for Refactored Cycle Execution ====================
def _validate_cycle_entry(self, cycle_id: str, nodes: List[str]) -> str | None:
"""
Validate that exactly one node in the cycle is triggered by external edges.
Args:
cycle_id: The cycle ID
nodes: List of node IDs in the cycle
Returns:
The ID of the unique initial node
Raises:
ValueError: If no node or multiple nodes are triggered
"""
triggered_nodes: List[str] = []
for node_id in nodes:
node = self.nodes[node_id]
# Check if any external predecessor (node outside the cycle) triggers this node
for predecessor in node.predecessors:
if predecessor.id not in nodes: # External node
edge = predecessor.find_outgoing_edge(node_id)
if edge and edge.trigger and edge.triggered:
triggered_nodes.append(node_id)
break
cycle_info = self.cycle_manager.cycles.get(cycle_id)
configured_entry = cycle_info.configured_entry_node if cycle_info else None
if len(triggered_nodes) == 0:
if configured_entry:
return configured_entry
return None
elif len(triggered_nodes) > 1:
raise ValueError(
f"Cycle {cycle_id} has multiple triggered entry nodes: {triggered_nodes}. "
"Only one entry node must be triggered when entering a cycle."
)
entry_node = triggered_nodes[0]
if configured_entry and entry_node != configured_entry:
raise ValueError(
f"Cycle {cycle_id} entry mismatch: configured '{configured_entry}' "
f"but triggered '{entry_node}'",
)
return entry_node
def _execute_cycle_with_iterations(
self,
cycle_id: str,
cycle_nodes: List[str],
initial_node_id: str,
max_iterations: int,
) -> Set[str]:
"""
Execute a cycle with multiple iterations.
Args:
cycle_id: Cycle ID
cycle_nodes: List of all nodes in the cycle
initial_node_id: Initial node ID
max_iterations: Maximum number of iterations
Returns:
A tuple of two sets:
- exit_nodes: nodes triggered outside the *current* cycle scope
- external_nodes: subset of exit_nodes that are also outside the
provided parent_cycle_nodes scope
"""
iteration = 0
while iteration < max_iterations:
self.log_manager.debug(
f"Cycle {cycle_id} iteration {iteration + 1}/{max_iterations}"
)
# Step 1: Detect nested cycles in the scoped subgraph
inner_cycles = self._detect_cycles_in_scope(cycle_nodes, initial_node_id)
# Build topological layers (whether there are nested cycles or not)
execution_layers = self._build_topological_layers_in_scope(
cycle_nodes, initial_node_id, inner_cycles,
is_first_iteration=(iteration == 0)
)
# Execute the topological layers
external_nodes = self._execute_scope_layers(
execution_layers,
cycle_id,
cycle_nodes,
initial_node_id=initial_node_id,
is_first_iteration=(iteration == 0)
)
if external_nodes:
self.log_manager.debug(
f"Cycle {cycle_id} exited - external nodes triggered: {sorted(external_nodes)}"
)
return external_nodes
# Step 4: Check if initial node is retriggered
if not self._is_initial_node_retriggered(initial_node_id, cycle_nodes):
self.log_manager.debug(
f"Cycle {cycle_id} completed - initial node not retriggered"
)
break
iteration += 1
if iteration >= max_iterations:
self.log_manager.warning(
f"Cycle {cycle_id} reached max iterations ({max_iterations})"
)
return set()
def _detect_cycles_in_scope(
self,
scope_nodes: List[str],
initial_node_id: str
) -> List[Set[str]]:
"""
Detect nested cycles within the scoped subgraph.
Constructs a subgraph containing only:
1. Nodes in scope_nodes
2. Edges where both source and target are in scope_nodes
3. Initial node's incoming edges are REMOVED (to break the outer cycle)
Args:
scope_nodes: List of node IDs in the current scope
initial_node_id: Initial node ID (whose incoming edges are removed)
Returns:
List of detected nested cycles (excluding the current cycle itself)
"""
# Build scoped nodes with initial node's incoming edges removed
scoped_nodes = self._build_scoped_nodes(scope_nodes, clear_entry_node=initial_node_id)
# Use GraphTopologyBuilder to detect cycles
all_cycles = GraphTopologyBuilder.detect_cycles(scoped_nodes)
# Filter out single-node "cycles" (unless they have self-loops)
nested_cycles = [
cycle for cycle in all_cycles
if len(cycle) > 1
]
return nested_cycles
def _build_scoped_nodes(
self,
scope_nodes: List[str],
clear_entry_node: Optional[str] = None
) -> Dict[str, Node]:
"""
Build a scoped subgraph containing only nodes and edges within the scope.
Args:
scope_nodes: List of node IDs in the scope
clear_entry_node: If specified, this node's incoming edges will be removed
(used to break the outer cycle when detecting nested cycles)
Returns:
Dictionary of scoped nodes
"""
scoped_nodes = {}
scope_nodes_set = set(scope_nodes)
for node_id in scope_nodes:
original_node = self.nodes[node_id]
# Shallow copy the node
scoped_node = copy.copy(original_node)
# Filter outgoing edges: only keep edges where target is in scope AND trigger=true
# Special case: if target is clear_entry_node, remove this edge
scoped_edges = [
edge_link for edge_link in original_node.iter_outgoing_edges()
if edge_link.target.id in scope_nodes_set
and edge_link.trigger
and edge_link.target.id != clear_entry_node # Remove edges to entry node
]
scoped_node._outgoing_edges = scoped_edges
# Filter predecessors: only keep predecessors in scope AND with trigger=true edge
# Special case: if this node is clear_entry_node, clear all predecessors
if node_id == clear_entry_node:
scoped_node.predecessors = []
else:
scoped_predecessors = []
for pred in original_node.predecessors:
if pred.id in scope_nodes_set:
# Check if the edge from pred to node has trigger=true
edge = pred.find_outgoing_edge(node_id)
if edge and edge.trigger:
scoped_predecessors.append(pred)
scoped_node.predecessors = scoped_predecessors
# Filter successors: only keep successors in scope AND with trigger=true edge
# Special case: remove clear_entry_node from successors
scoped_successors = [
succ for succ in original_node.successors
if succ.id in scope_nodes_set
and succ.id != clear_entry_node # Remove entry node from successors
and any(
edge_link.target.id == succ.id and edge_link.trigger
for edge_link in original_node.iter_outgoing_edges()
)
]
scoped_node.successors = scoped_successors
scoped_nodes[node_id] = scoped_node
return scoped_nodes
def _build_topological_layers_in_scope(
self,
scope_nodes: List[str],
initial_node_id: str,
inner_cycles: List[Set[str]],
is_first_iteration: bool = False
) -> List[Dict[str, Any]]:
"""
Build topological execution order for the scoped subgraph.
Args:
scope_nodes: List of node IDs in the scope
initial_node_id: Initial node ID
inner_cycles: List of nested cycles detected in the scope
is_first_iteration: Whether this is the first iteration (affects initial node handling)
Returns:
List of execution layers, each containing execution items
"""
# Build scoped nodes WITHOUT clearing entry node
# We want to keep all edges intact for execution
scoped_nodes = self._build_scoped_nodes(scope_nodes, clear_entry_node=None)
# Handle entry points based on iteration:
# - First iteration: manually clear initial node's predecessors (for in_degree calculation only)
# - Subsequent iterations: clear predecessors for all triggered nodes
if is_first_iteration:
# Clear initial node's predecessors to make it an entry point
if initial_node_id in scoped_nodes:
scoped_nodes[initial_node_id].predecessors = []
else:
# Subsequent iterations: clear predecessors for all triggered nodes
for node_id in scope_nodes:
if self.nodes[node_id].is_triggered():
scoped_nodes[node_id].predecessors = []
# Extract scoped edges from scoped_nodes (not original nodes)
# This ensures consistency with the filtered graph structure
scoped_edges = []
# Collect nodes whose incoming edges should be excluded
# (to break cycles in topological sorting)
exclude_targets = set()
if is_first_iteration:
# First iteration: exclude edges to initial_node
exclude_targets.add(initial_node_id)
else:
# Subsequent iterations: exclude edges to all triggered nodes
for node_id in scope_nodes:
if self.nodes[node_id].is_triggered():
exclude_targets.add(node_id)
for node_id in scope_nodes:
# Use scoped_node to get filtered edges
scoped_node = scoped_nodes.get(node_id)
if scoped_node:
for edge_link in scoped_node.iter_outgoing_edges():
# Exclude edges pointing to nodes in exclude_targets
if edge_link.target.id in exclude_targets:
continue
scoped_edges.append({
"from": node_id,
"to": edge_link.target.id,
"trigger": edge_link.trigger,
"condition": edge_link.condition
})
# Use GraphTopologyBuilder to build execution order
if not inner_cycles:
# No nested cycles, use DAG layers
layers = GraphTopologyBuilder.build_dag_layers(scoped_nodes)
return layers
else:
# Has nested cycles, use super-node approach
super_graph = GraphTopologyBuilder.create_super_node_graph(
scoped_nodes, scoped_edges, inner_cycles
)
layers = GraphTopologyBuilder.topological_sort_super_nodes(
super_graph, inner_cycles
)
return layers
def _execute_scope_layers(
self,
execution_layers: List[List[Dict[str, Any]]],
parent_cycle_id: str,
parent_cycle_nodes: List[str],
initial_node_id: Optional[str] = None,
is_first_iteration: bool = False
) -> Set[str]:
"""
Execute scoped layers with parallelism, supporting nested cycles.
Args:
execution_layers: List of execution layers
parent_cycle_id: Parent cycle ID
parent_cycle_nodes: List of nodes in the parent cycle
initial_node_id: Initial node ID (for first iteration special handling)
is_first_iteration: Whether this is the first iteration
Returns:
external_nodes: subset of exit_nodes outside parent_cycle_nodes_set
"""
scope_node_set = set(parent_cycle_nodes)
external_nodes: Set[str] = set()
stop_event = threading.Event()
result_lock = threading.Lock()
def record_external(nodes: Set[str]) -> None:
nonlocal external_nodes
if not nodes:
return
with result_lock:
if nodes:
external_nodes.update(nodes)
stop_event.set()
def item_desc(item: Dict[str, Any]) -> str:
if item["type"] == "node":
return f"node {item['node_id']}"
if item["type"] == "cycle":
return f"cycle {item['cycle_id']}"
return "layer_item"
for layer in execution_layers:
if stop_event.is_set():
break
def executor_func(item: Dict[str, Any]) -> None:
if stop_event.is_set():
return
if item["type"] == "node":
_node_id = item["node_id"]
force_execute = is_first_iteration and (_node_id == initial_node_id)
targets = self._execute_single_cycle_node_in_scope(
_node_id,
scope_node_set,
force_execute=force_execute
)
if targets:
record_external(targets)
elif item["type"] == "cycle":
inner_cycle_nodes = item["nodes"]
inner_cycle_id = item["cycle_id"]
self.log_manager.debug(
f"Executing nested cycle {inner_cycle_id} within cycle {parent_cycle_id}"
)
try:
inner_initial_node = self._validate_cycle_entry(
inner_cycle_id, inner_cycle_nodes
)
except ValueError as e:
self.log_manager.error(str(e))
raise
if inner_initial_node is None:
self.log_manager.debug(
f"Nested cycle {inner_cycle_id} has no triggered entry; skipping"
)
return
inner_external_nodes = self._execute_cycle_with_iterations(
inner_cycle_id,
inner_cycle_nodes,
inner_initial_node,
max_iterations=100,
)
if inner_external_nodes:
filtered = {
node
for node in inner_external_nodes
if node not in scope_node_set
}
if filtered:
record_external(filtered)
self.parallel_executor.execute_items_parallel(
layer,
executor_func,
item_desc
)
if stop_event.is_set():
break
if external_nodes:
for node_id in scope_node_set:
self.nodes[node_id].reset_triggers()
return external_nodes
def _execute_single_cycle_node_in_scope(
self,
node_id: str,
scope_node_set: Set[str],
force_execute: bool = False
) -> Set[str]:
"""
Execute a single node within a cycle scope.
Args:
node_id: Node ID to execute
scope_node_set: Nodes that belong to the current scoped cycle
force_execute: If True, execute even if not triggered (for initial node in first iteration)
Returns:
Set of node IDs triggered outside the current scoped cycle
"""
node = self.nodes[node_id]
# Check if node is triggered (unless force_execute is True)
if not force_execute:
if not node.is_triggered():
return set()
# Reset edge triggers
for edge_link in node.iter_outgoing_edges():
edge_link.triggered = False
# Execute the node
self.execute_node_func(node)
# Check if any external node was triggered
external_targets: Set[str] = set()
for edge_link in node.iter_outgoing_edges():
if edge_link.target.id not in scope_node_set and edge_link.triggered:
self.log_manager.debug(
f"Node {node_id} triggered external node {edge_link.target.id}"
)
external_targets.add(edge_link.target.id)
return external_targets
def _is_initial_node_retriggered(
self,
initial_node_id: str,
cycle_nodes: List[str]
) -> bool:
"""
Check if the initial node is retriggered by any internal edge (from within the cycle).
Args:
initial_node_id: Initial node ID
cycle_nodes: List of nodes in the cycle
Returns:
True if the initial node is retriggered by an internal edge
"""
initial_node = self.nodes[initial_node_id]
for predecessor in initial_node.predecessors:
# Only check predecessors within the cycle
if predecessor.id in cycle_nodes:
edge = predecessor.find_outgoing_edge(initial_node_id)
if edge and edge.trigger and edge.triggered:
return True
return False
+55
View File
@@ -0,0 +1,55 @@
"""Executor for DAG (Directed Acyclic Graph) workflows."""
from typing import Dict, List, Callable
from entity.configs import Node
from utils.log_manager import LogManager
from workflow.executor.parallel_executor import ParallelExecutor
class DAGExecutor:
"""Execute DAG workflows.
Features:
- Execute layer by layer following the topology
- Support parallel execution inside a layer
- Serialize Human nodes automatically
"""
def __init__(
self,
log_manager: LogManager,
nodes: Dict[str, Node],
layers: List[List[str]],
execute_node_func: Callable[[Node], None]
):
"""Initialize the executor.
Args:
log_manager: Logger instance
nodes: Mapping of node ids to ``Node`` objects
layers: Topological layers
execute_node_func: Callable used to execute a single node
"""
self.log_manager = log_manager
self.nodes = nodes
self.layers = layers
self.execute_node_func = execute_node_func
self.parallel_executor = ParallelExecutor(log_manager, nodes)
def execute(self) -> None:
"""Execute the DAG workflow."""
for layer_idx, layer_nodes in enumerate(self.layers):
self.log_manager.debug(f"Executing Layer {layer_idx} with nodes: {layer_nodes}")
self._execute_layer(layer_nodes)
def _execute_layer(self, layer_nodes: List[str]) -> None:
"""Execute a single topological layer."""
def execute_if_triggered(node_id: str) -> None:
node = self.nodes[node_id]
if node.is_triggered():
self.execute_node_func(node)
else:
self.log_manager.debug(f"Node {node_id} skipped - not triggered")
self.parallel_executor.execute_nodes_parallel(layer_nodes, execute_if_triggered)
+399
View File
@@ -0,0 +1,399 @@
"""Dynamic edge executor for edge-level Map and Tree execution.
Handles dynamic node expansion based on edge-level dynamic configuration.
When a message passes through an edge with dynamic config, the target node
is virtually expanded into multiple instances based on split results.
"""
import concurrent.futures
from typing import Callable, Dict, List, Optional
from entity.configs import Node
from entity.configs.edge.dynamic_edge_config import DynamicEdgeConfig
from entity.messages import Message, MessageRole
from runtime.node.splitter import create_splitter_from_config, group_messages
from utils.log_manager import LogManager
class DynamicEdgeExecutor:
"""Execute edge-level dynamic expansion.
When an edge has dynamic configuration, this executor:
1. Splits the payload passing through the edge
2. Executes the target node for each split unit
3. Collects and returns results (flat for Map, reduced for Tree)
"""
def __init__(
self,
log_manager: LogManager,
node_executor_func: Callable[[Node, List[Message]], List[Message]],
):
"""Initialize the dynamic edge executor.
Args:
log_manager: Logger instance
node_executor_func: Function to execute a node with inputs
"""
self.log_manager = log_manager
self.node_executor_func = node_executor_func
def execute(
self,
target_node: Node,
payload: Message,
dynamic_config: DynamicEdgeConfig,
static_inputs: Optional[List[Message]] = None,
) -> List[Message]:
"""Execute dynamic expansion for an edge.
Args:
target_node: The node to execute (will be used as template)
payload: The message passing through the edge
dynamic_config: Edge dynamic configuration
static_inputs: Optional static inputs from non-dynamic edges
Returns:
List of output messages from all executions
"""
split_config = dynamic_config.split
# Create splitter based on config
splitter = create_splitter_from_config(split_config)
# Split the payload into execution units
execution_units = splitter.split([payload])
if not execution_units:
self.log_manager.debug(
f"Dynamic edge -> {target_node.id}: no execution units after split"
)
return []
self.log_manager.info(
f"Dynamic edge -> {target_node.id}: splitting into {len(execution_units)} parallel units"
)
if dynamic_config.is_map():
return self._execute_map(
target_node, execution_units, dynamic_config, static_inputs
)
elif dynamic_config.is_tree():
return self._execute_tree(
target_node, execution_units, dynamic_config, static_inputs
)
else:
raise ValueError(f"Unknown dynamic type: {dynamic_config.type}")
def execute_from_inputs(
self,
target_node: Node,
inputs: List[Message],
dynamic_config: DynamicEdgeConfig,
static_inputs: Optional[List[Message]] = None,
) -> List[Message]:
"""Execute dynamic expansion using all collected inputs.
This method is called from _execute_node when a node has incoming edges
with dynamic configuration. All inputs are already collected and passed here.
Args:
target_node: The node to execute
inputs: Dynamic edge inputs to be split
dynamic_config: Edge dynamic configuration
static_inputs: Non-dynamic edge inputs to be replicated to all units
Returns:
List of output messages from all executions
"""
split_config = dynamic_config.split
static_inputs = static_inputs or []
# Create splitter based on config
splitter = create_splitter_from_config(split_config)
# Split only dynamic inputs into execution units
execution_units = splitter.split(inputs)
if not execution_units:
self.log_manager.debug(
f"Dynamic node {target_node.id}: no execution units after split"
)
# If no dynamic inputs but have static inputs, execute once with static inputs
if static_inputs:
return self.node_executor_func(target_node, static_inputs)
return []
self.log_manager.info(
f"Dynamic node {target_node.id}: splitting {len(inputs)} dynamic inputs into "
f"{len(execution_units)} parallel units ({dynamic_config.type} mode)"
+ (f", with {len(static_inputs)} static inputs replicated to each" if static_inputs else "")
)
if dynamic_config.is_map():
return self._execute_map(
target_node, execution_units, dynamic_config, static_inputs
)
elif dynamic_config.is_tree():
return self._execute_tree(
target_node, execution_units, dynamic_config, static_inputs
)
else:
raise ValueError(f"Unknown dynamic type: {dynamic_config.type}")
def _execute_map(
self,
target_node: Node,
execution_units: List[List[Message]],
dynamic_config: DynamicEdgeConfig,
static_inputs: Optional[List[Message]] = None,
) -> List[Message]:
"""Execute in Map mode (fan-out only).
Args:
target_node: Target node template
execution_units: Split message units
dynamic_config: Dynamic configuration
static_inputs: Static inputs to copy to all units
Returns:
Flat list of all output messages
"""
map_config = dynamic_config.as_map_config()
max_parallel = map_config.max_parallel
all_outputs: List[Message] = []
static_inputs = static_inputs or []
if len(execution_units) == 1:
# Single unit - execute directly
unit_inputs = list(static_inputs) + execution_units[0]
outputs = self._execute_unit(target_node, unit_inputs, 0)
all_outputs.extend(outputs)
else:
# Multiple units - parallel execution
effective_workers = min(len(execution_units), max_parallel)
with concurrent.futures.ThreadPoolExecutor(max_workers=effective_workers) as executor:
futures: Dict[concurrent.futures.Future, int] = {}
for idx, unit in enumerate(execution_units):
unit_inputs = list(static_inputs) + unit
future = executor.submit(
self._execute_unit, target_node, unit_inputs, idx
)
futures[future] = idx
results_by_idx: Dict[int, List[Message]] = {}
for future in concurrent.futures.as_completed(futures):
idx = futures[future]
try:
result = future.result()
results_by_idx[idx] = result
self.log_manager.debug(
f"Dynamic edge -> {target_node.id}#{idx}: "
f"completed with {len(result)} outputs"
)
except Exception as e:
self.log_manager.error(
f"Dynamic edge -> {target_node.id}#{idx}: "
f"failed with error: {e}"
)
raise
# Combine results in original order
for idx in range(len(execution_units)):
if idx in results_by_idx:
all_outputs.extend(results_by_idx[idx])
self.log_manager.info(
f"Dynamic edge -> {target_node.id}: "
f"Map completed with {len(all_outputs)} total outputs"
)
return all_outputs
def _execute_tree(
self,
target_node: Node,
execution_units: List[List[Message]],
dynamic_config: DynamicEdgeConfig,
static_inputs: Optional[List[Message]] = None,
) -> List[Message]:
"""Execute in Tree mode (fan-out + reduce).
Args:
target_node: Target node template
execution_units: Split message units
dynamic_config: Dynamic configuration
static_inputs: Static inputs (used in first layer only)
Returns:
Single-element list with the final reduced result
"""
tree_config = dynamic_config.as_tree_config()
if tree_config is None:
raise ValueError(f"Invalid tree configuration for edge -> {target_node.id}")
group_size = tree_config.group_size
max_parallel = tree_config.max_parallel
static_inputs = static_inputs or []
# Flatten execution units to individual messages
current_messages: List[Message] = []
for unit in execution_units:
current_messages.extend(unit)
if not current_messages:
return []
self.log_manager.info(
f"Dynamic edge -> {target_node.id}: "
f"Tree starting with {len(current_messages)} inputs, group_size={group_size}"
)
layer = 0
is_first_layer = True
# Reduction loop
while len(current_messages) > 1:
layer += 1
# Group messages
groups = group_messages(current_messages, group_size)
self.log_manager.debug(
f"Dynamic edge -> {target_node.id} layer {layer}: "
f"processing {len(groups)} groups"
)
layer_outputs: List[Message] = []
if len(groups) == 1:
# Single group - execute directly
group_inputs = groups[0]
if is_first_layer:
group_inputs = list(static_inputs) + group_inputs
outputs = self._execute_group(target_node, group_inputs, layer, 0)
layer_outputs.extend(outputs)
else:
# Multiple groups - parallel execution
effective_workers = min(len(groups), max_parallel)
with concurrent.futures.ThreadPoolExecutor(max_workers=effective_workers) as executor:
futures: Dict[concurrent.futures.Future, int] = {}
for idx, group in enumerate(groups):
group_inputs = group
if is_first_layer:
group_inputs = list(static_inputs) + group_inputs
future = executor.submit(
self._execute_group, target_node, group_inputs, layer, idx
)
futures[future] = idx
results_by_idx: Dict[int, List[Message]] = {}
for future in concurrent.futures.as_completed(futures):
idx = futures[future]
try:
result = future.result()
results_by_idx[idx] = result
except Exception as e:
self.log_manager.error(
f"Dynamic edge -> {target_node.id}#{layer}-{idx}: "
f"failed with error: {e}"
)
raise
for idx in range(len(groups)):
if idx in results_by_idx:
layer_outputs.extend(results_by_idx[idx])
self.log_manager.debug(
f"Dynamic edge -> {target_node.id} layer {layer}: "
f"produced {len(layer_outputs)} outputs"
)
current_messages = layer_outputs
is_first_layer = False
# Safety check
if layer > 100:
self.log_manager.error(
f"Dynamic edge -> {target_node.id}: exceeded maximum layers"
)
break
self.log_manager.info(
f"Dynamic edge -> {target_node.id}: "
f"Tree completed after {layer} layers with {len(current_messages)} output(s)"
)
return current_messages
def _execute_unit(
self,
node: Node,
unit_inputs: List[Message],
unit_index: int,
) -> List[Message]:
"""Execute a single map unit."""
self.log_manager.debug(
f"Dynamic edge -> {node.id}#{unit_index}: "
f"executing with {len(unit_inputs)} inputs"
)
# Tag inputs with unit index
# Clone messages first to avoid mutating shared inputs in parallel threads
unit_inputs = [msg.clone() for msg in unit_inputs]
for msg in unit_inputs:
metadata = dict(msg.metadata)
metadata["dynamic_edge_unit_index"] = unit_index
msg.metadata = metadata
# Execute using node executor
outputs = self.node_executor_func(node, unit_inputs)
# Tag outputs with unit index
for msg in outputs:
metadata = dict(msg.metadata)
metadata["dynamic_edge_unit_index"] = unit_index
msg.metadata = metadata
return outputs
def _execute_group(
self,
node: Node,
group_inputs: List[Message],
layer: int,
group_index: int,
) -> List[Message]:
"""Execute a single tree group."""
instance_id = f"{node.id}#{layer}-{group_index}"
self.log_manager.debug(
f"Dynamic edge -> {instance_id}: executing with {len(group_inputs)} inputs"
)
# Tag inputs
# Clone messages first to avoid mutating shared inputs in parallel threads
group_inputs = [msg.clone() for msg in group_inputs]
for msg in group_inputs:
metadata = dict(msg.metadata)
metadata["dynamic_edge_tree_layer"] = layer
metadata["dynamic_edge_tree_group"] = group_index
msg.metadata = metadata
# Execute
outputs = self.node_executor_func(node, group_inputs)
# Tag outputs
for msg in outputs:
metadata = dict(msg.metadata)
metadata["dynamic_edge_tree_layer"] = layer
metadata["dynamic_edge_tree_group"] = group_index
metadata["dynamic_edge_instance_id"] = instance_id
msg.metadata = metadata
msg.role = MessageRole.USER # Mark as user-generated
return outputs
+141
View File
@@ -0,0 +1,141 @@
"""Parallel execution helpers that eliminate duplicated code."""
import concurrent.futures
from typing import Any, Callable, List, Tuple
from utils.log_manager import LogManager
class ParallelExecutor:
"""Manage parallel execution for workflow nodes.
Provides shared logic for parallel batches and serializes Human nodes when needed.
"""
def __init__(self, log_manager: LogManager, nodes_dict: dict):
"""Initialize the parallel executor.
Args:
log_manager: Logger instance
nodes_dict: Mapping of ``node_id`` to ``Node``
"""
self.log_manager = log_manager
self.nodes_dict = nodes_dict
def execute_items_parallel(
self,
items: List[Any],
executor_func: Callable,
item_desc_func: Callable[[Any], str],
has_blocking_func: Callable[[Any], bool] | None = None,
) -> None:
"""Execute a list of items in parallel when possible.
Args:
items: Items to execute
executor_func: Callable that executes a single item
item_desc_func: Callable for logging a human-readable description
has_blocking_func: Optional callable to decide if an item requires serialization
"""
blocking_items, parallel_items = self._partition_blocking_items(items, has_blocking_func)
if parallel_items:
self._execute_parallel_batch(parallel_items, executor_func, item_desc_func)
if blocking_items:
self._execute_sequential_batch(blocking_items, executor_func, item_desc_func)
def execute_nodes_parallel(
self,
node_ids: List[str],
executor_func: Callable[[str], None]
) -> None:
"""Execute a list of nodes in parallel.
Convenience wrapper around ``execute_items_parallel`` specialized for nodes.
Args:
node_ids: List of node identifiers
executor_func: Callable that executes a single node
"""
def item_desc_func(node_id: str) -> str:
return f"node {node_id}"
def has_blocking_func(node_id: str) -> bool:
return False
self.execute_items_parallel(
node_ids,
executor_func,
item_desc_func,
has_blocking_func
)
def _partition_blocking_items(
self,
items: List[Any],
has_blocking_func: Callable[[Any], bool] | None
) -> Tuple[List[Any], List[Any]]:
"""Split items into blocking and parallelizable lists."""
blocking_items = []
parallel_items = []
for item in items:
if has_blocking_func and has_blocking_func(item):
blocking_items.append(item)
else:
parallel_items.append(item)
return blocking_items, parallel_items
def _execute_parallel_batch(
self,
items: List[Any],
executor_func: Callable,
item_desc_func: Callable[[Any], str]
) -> None:
"""Execute a batch of items in parallel.
Args:
items: Items to execute
executor_func: Callable per item
item_desc_func: Callable returning a readable description
"""
self.log_manager.debug(f"Executing {len(items)} items in parallel")
with concurrent.futures.ThreadPoolExecutor(max_workers=len(items)) as executor:
futures = []
for item in items:
future = executor.submit(executor_func, item)
futures.append((item, future))
# Wait for every future to finish
for item, future in futures:
try:
future.result()
self.log_manager.debug(f"{item_desc_func(item)} completed successfully")
except Exception as e:
self.log_manager.error(f"{item_desc_func(item)} failed: {str(e)}")
raise
def _execute_sequential_batch(
self,
items: List[Any],
executor_func: Callable,
item_desc_func: Callable[[Any], str]
) -> None:
"""Execute a batch of items sequentially.
Args:
items: Items to execute
executor_func: Callable per item
item_desc_func: Callable returning a readable description
"""
for item in items:
self.log_manager.debug(f"Executing {item_desc_func(item)} (sequential)")
try:
executor_func(item)
self.log_manager.debug(f"{item_desc_func(item)} completed successfully")
except Exception as e:
self.log_manager.error(f"{item_desc_func(item)} failed: {str(e)}")
raise
+82
View File
@@ -0,0 +1,82 @@
"""Resource coordination helpers for workflow node execution."""
import threading
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Dict, Iterable, List, Tuple
from entity.configs import Node
from runtime.node.registry import get_node_registration
from utils.log_manager import LogManager
@dataclass(frozen=True, slots=True)
class ResourceRequest:
"""Represents a single resource requirement."""
key: str
limit: int
@dataclass(slots=True)
class _ResourceSlot:
semaphore: threading.Semaphore
limit: int
class ResourceManager:
"""Coordinates shared resource usage across nodes."""
def __init__(self, log_manager: LogManager | None = None):
self.log_manager = log_manager
self._lock = threading.Lock()
self._resources: Dict[str, _ResourceSlot] = {}
@contextmanager
def guard_node(self, node: Node):
"""Acquire all resources required by the given node."""
requests = self._resolve_node_requests(node)
with self._acquire_resources(requests):
yield
def _resolve_node_requests(self, node: Node) -> List[ResourceRequest]:
registration = get_node_registration(node.node_type)
caps = registration.capabilities
requests: List[ResourceRequest] = []
key = caps.resource_key
limit = caps.resource_limit
if key and limit and limit > 0:
requests.append(ResourceRequest(key=key, limit=limit))
return requests
@contextmanager
def _acquire_resources(self, requests: Iterable[ResourceRequest]):
acquired: List[Tuple[str, threading.Semaphore]] = []
try:
for request in sorted(requests, key=lambda item: item.key):
semaphore = self._get_or_create_resource(request)
self._log_debug(f"Acquiring resource {request.key}")
semaphore.acquire()
acquired.append((request.key, semaphore))
yield
finally:
for key, semaphore in reversed(acquired):
semaphore.release()
self._log_debug(f"Released resource {key}")
def _get_or_create_resource(self, request: ResourceRequest) -> threading.Semaphore:
with self._lock:
slot = self._resources.get(request.key)
if slot and slot.limit != request.limit:
slot = None
if not slot:
slot = _ResourceSlot(
semaphore=threading.Semaphore(request.limit),
limit=request.limit,
)
self._resources[request.key] = slot
return slot.semaphore
def _log_debug(self, message: str) -> None:
if self.log_manager:
self.log_manager.debug(message)
+860
View File
@@ -0,0 +1,860 @@
"""Graph orchestration adapted to ChatDev design_0.4.0 workflows."""
import threading
from typing import Any, Callable, Dict, List, Optional
from runtime.node.agent.memory import MemoryBase, MemoryFactory, MemoryManager
from runtime.node.agent.thinking import ThinkingManagerBase, ThinkingManagerFactory
from entity.configs import Node, EdgeLink, AgentConfig, ConfigError
from entity.configs.edge import EdgeConditionConfig
from entity.configs.node.memory import SimpleMemoryConfig
from entity.messages import Message, MessageRole
from runtime.node.executor.base import ExecutionContext
from runtime.node.executor.factory import NodeExecutorFactory
from utils.logger import WorkflowLogger
from utils.exceptions import ValidationError, WorkflowExecutionError, WorkflowCancelledError
from utils.structured_logger import get_server_logger
from utils.human_prompt import (
CliPromptChannel,
HumanPromptService,
resolve_prompt_channel,
)
from workflow.cycle_manager import CycleManager
from workflow.graph_context import GraphContext
from workflow.graph_manager import GraphManager
from workflow.executor.resource_manager import ResourceManager
from workflow.runtime import (
RuntimeBuilder,
ResultArchiver,
DagExecutionStrategy,
CycleExecutionStrategy,
MajorityVoteStrategy,
)
from workflow.runtime.runtime_context import RuntimeContext
from runtime.edge.conditions import (
ConditionFactoryContext,
build_edge_condition_manager,
)
from runtime.edge.processors import (
ProcessorFactoryContext as PayloadProcessorFactoryContext,
build_edge_processor as build_edge_payload_processor,
)
from workflow.executor.dynamic_edge_executor import DynamicEdgeExecutor
# ------------------------------------------------------------------
# Executor class (includes all Memory and Thinking logic)
# ------------------------------------------------------------------
class ExecutionError(RuntimeError):
"""Raised when the workflow graph cannot be executed."""
class GraphExecutor:
"""Executes ChatDev_new graph workflows with integrated memory and thinking management."""
def __init__(
self,
graph: GraphContext,
*,
session_id: Optional[str] = None,
workspace_hook_factory: Optional[Callable[[RuntimeContext], Any]] = None,
cancel_event: Optional[threading.Event] = None,
) -> None:
"""Initialize executor with graph context instance."""
self.majority_result = None
self.graph: GraphContext = graph
self.outputs = {}
self.logger = self._create_logger()
self._cancel_event = cancel_event or threading.Event()
self._cancel_reason: Optional[str] = None
runtime = RuntimeBuilder(graph).build(logger=self.logger, session_id=session_id)
if workspace_hook_factory:
runtime.workspace_hook = workspace_hook_factory(runtime)
self.runtime_context = runtime
self.tool_manager = runtime.tool_manager
self.function_manager = runtime.function_manager
self.edge_processor_function_manager = runtime.edge_processor_function_manager
self.log_manager = runtime.log_manager
self.resource_manager = ResourceManager(self.log_manager)
# Memory and Thinking management (moved from Graph)
self.thinking_managers: Dict[str, ThinkingManagerBase] = {}
self.global_memories: Dict[str, MemoryBase] = {}
self.agent_memory_managers: Dict[str, MemoryManager] = {}
# Token tracking
self.token_tracker = runtime.token_tracker
# Workspace roots
self.code_workspace = runtime.code_workspace
self.attachment_store = runtime.attachment_store
# Cycle management
self.cycle_manager: Optional[CycleManager] = None
# Node executors (new strategy pattern implementation)
self.__execution_context: Optional[ExecutionContext] = None
self.node_executors: Dict[str, Any] = {}
self._human_prompt_service: Optional[HumanPromptService] = None
# for majority voting mode
self.initial_task_messages: List[Message] = []
def request_cancel(self, reason: Optional[str] = None) -> None:
"""Signal the executor to stop as soon as possible."""
if reason:
self._cancel_reason = reason
elif not self._cancel_reason:
self._cancel_reason = "Workflow execution cancelled"
self._cancel_event.set()
self.logger.info(f"Cancellation requested for workflow {self.graph.name}")
def is_cancelled(self) -> bool:
return self._cancel_event.is_set()
def _raise_if_cancelled(self) -> None:
if self.is_cancelled():
message = self._cancel_reason or "Workflow execution cancelled"
raise WorkflowCancelledError(message, workflow_id=self.graph.name)
def _create_logger(self) -> WorkflowLogger:
"""Create and return a logger instance."""
return WorkflowLogger(self.graph.name, self.graph.log_level)
@classmethod
def execute_graph(
cls,
graph: GraphContext,
task_prompt: Any,
*,
cancel_event: Optional[threading.Event] = None,
) -> "GraphExecutor":
"""Convenience method to execute a graph with a task prompt."""
executor = cls(graph, cancel_event=cancel_event)
executor._execute(task_prompt)
return executor
def _execute(self, task_prompt: Any):
self._raise_if_cancelled()
results = self.run(task_prompt)
self.graph.record(results)
def _build_memories_and_thinking(self) -> None:
"""Initialize all memory and thinking managers before execution."""
self._build_global_memories()
self._build_thinking_managers()
self._build_agent_memories()
self._build_node_executors()
def _build_global_memories(self) -> None:
"""Build global memories from config."""
memory_config = self.graph.config.get_memory_config()
if not memory_config:
return
for store in memory_config:
if store.name in self.global_memories:
error_msg = f"Duplicated memory name detected: {store.name}"
self.log_manager.error(error_msg)
raise ValidationError(error_msg, details={"memory_name": store.name})
simple_cfg = store.as_config(SimpleMemoryConfig)
if simple_cfg and (not simple_cfg.memory_path or simple_cfg.memory_path == "auto"):
path = self.graph.directory / f"memory_{store.name}.json"
simple_cfg.memory_path = str(path)
try:
memory_instance = MemoryFactory.create_memory(store)
self.global_memories[store.name] = memory_instance
memory_instance.load()
self.log_manager.info(
f"Global memory '{store.name}' built successfully",
details={"memory_name": store.name},
)
except Exception as e:
error_msg = f"Failed to create memory '{store.name}': {str(e)}"
self.log_manager.error(error_msg, details={"memory_name": store.name})
logger = get_server_logger()
logger.log_exception(e, error_msg, memory_name=store.name)
raise WorkflowExecutionError(error_msg, details={"memory_name": store.name})
def _build_thinking_managers(self) -> None:
"""Build thinking managers for nodes that require them."""
for node_id, node in self.graph.nodes.items():
agent_config = node.as_config(AgentConfig)
if agent_config and agent_config.thinking:
self.thinking_managers[node_id] = ThinkingManagerFactory.get_thinking_manager(
agent_config.thinking
)
def _build_agent_memories(self) -> None:
"""Build memory managers for agent nodes referencing global stores."""
for node_id, node in self.graph.nodes.items():
agent_config = node.as_config(AgentConfig)
if not (agent_config and agent_config.memories):
continue
try:
self.agent_memory_managers[node_id] = MemoryManager(agent_config.memories, self.global_memories)
self.log_manager.info(
f"Memory manager built for node {node_id}",
node_id=node_id,
details={"memory_refs": [mem.name for mem in agent_config.memories]},
)
except Exception as e:
error_msg = f"Failed to create memory manager for node {node_id}: {str(e)}"
self.log_manager.error(error_msg, node_id=node_id)
logger = get_server_logger()
logger.log_exception(e, error_msg, node_id=node_id)
raise WorkflowExecutionError(error_msg, node_id=node_id)
def _get_execution_context(self) -> ExecutionContext:
if self.__execution_context is None:
global_state = dict(self.runtime_context.global_state)
global_state.setdefault("attachment_store", self.attachment_store)
prompt_service = self._ensure_human_prompt_service()
global_state.setdefault("human_prompt", prompt_service)
self.__execution_context = ExecutionContext(
tool_manager=self.tool_manager,
function_manager=self.function_manager,
log_manager=self.log_manager,
memory_managers=self.agent_memory_managers,
thinking_managers=self.thinking_managers,
token_tracker=self.token_tracker,
global_state=global_state,
workspace_hook=self.runtime_context.workspace_hook,
human_prompt_service=prompt_service,
cancel_event=self._cancel_event,
)
return self.__execution_context
def _build_node_executors(self) -> None:
"""Build node executors using strategy pattern."""
# Create node executors
self.node_executors = NodeExecutorFactory.create_executors(
self._get_execution_context(),
self.graph.subgraphs
)
def _ensure_human_prompt_service(self) -> HumanPromptService:
if self._human_prompt_service:
return self._human_prompt_service
channel = resolve_prompt_channel(self.runtime_context.workspace_hook)
if channel is None:
channel = CliPromptChannel()
self._human_prompt_service = HumanPromptService(
log_manager=self.log_manager,
channel=channel,
session_id=self.runtime_context.session_id,
)
return self._human_prompt_service
def _save_memories(self) -> None:
"""Save all memories after execution."""
for memory in self.global_memories.values():
memory.save()
def run(self, task_prompt: Any) -> Dict[str, Any]:
"""Execute the graph based on topological layers structure or cycle-aware execution."""
self._raise_if_cancelled()
graph_manager = GraphManager(self.graph)
try:
graph_manager.build_graph()
except ConfigError as err:
error_msg = f"Graph configuration error: {str(err)}"
self.log_manager.logger.error(error_msg)
raise err
self._prepare_edge_conditions()
if not self.graph.layers:
raise ExecutionError("Graph not built. Call GraphManager.build_graph() first.")
# Record workflow start
self.log_manager.record_workflow_start(self.graph.metadata)
# Initialize memory and thinking before execution
self._build_memories_and_thinking()
# Initialize cycle manager if graph has cycles
if self.graph.has_cycles:
self.cycle_manager = graph_manager.get_cycle_manager()
self.initial_task_messages = [msg.clone() for msg in self._normalize_task_input(task_prompt)]
start_node_ids = set(self.graph.start_nodes)
# Reset all trigger states and initialize configured start nodes
for node_id, node in self.graph.nodes.items():
self._raise_if_cancelled()
node.reset_triggers()
if node_id in start_node_ids:
node.start_triggered = True
node.clear_input()
for message in self.initial_task_messages:
node.append_input(message.clone())
# Execute based on graph type (using strategy objects)
if self.graph.is_majority_voting:
strategy = MajorityVoteStrategy(
log_manager=self.log_manager,
nodes=self.graph.nodes,
initial_messages=self.initial_task_messages,
execute_node_func=self._execute_node,
payload_to_text_func=self._payload_to_text,
)
self.majority_result = strategy.run()
elif self.graph.has_cycles:
strategy = CycleExecutionStrategy(
log_manager=self.log_manager,
nodes=self.graph.nodes,
cycle_execution_order=self.graph.cycle_execution_order,
cycle_manager=self.cycle_manager,
execute_node_func=self._execute_node,
)
strategy.run()
else:
strategy = DagExecutionStrategy(
log_manager=self.log_manager,
nodes=self.graph.nodes,
layers=self.graph.layers,
execute_node_func=self._execute_node,
)
strategy.run()
self._raise_if_cancelled()
# Collect final outputs and save memories
self._collect_all_outputs()
# Get the final result according to the new logic
final_result = self.get_final_output()
self._save_memories()
# Export runtime artifacts
archiver = ResultArchiver(self.graph, self.log_manager, self.token_tracker)
archiver.export(final_result)
return self.outputs
def _prepare_edge_conditions(self) -> None:
"""Compile registered edge condition types into callable evaluators."""
context = ConditionFactoryContext(function_manager=self.function_manager, log_manager=self.log_manager)
processor_context = PayloadProcessorFactoryContext(
function_manager=self.edge_processor_function_manager,
log_manager=self.log_manager,
)
for node in self.graph.nodes.values():
for edge_link in node.iter_outgoing_edges():
condition_config = edge_link.condition_config
if not isinstance(condition_config, EdgeConditionConfig):
raw_value = edge_link.config.get("condition", "true")
condition_config = EdgeConditionConfig.from_dict(raw_value, path=f"{node.path}.edges")
edge_link.condition_config = condition_config
try:
manager = build_edge_condition_manager(condition_config, context, self._get_execution_context())
except Exception as exc: # pragma: no cover - defensive logging
error_msg = f"Failed to prepare condition '{condition_config.display_label()}': {exc}"
self.log_manager.error(error_msg)
logger = get_server_logger()
logger.log_exception(exc, error_msg, condition_type=condition_config.type)
raise WorkflowExecutionError(error_msg) from exc
edge_link.condition_manager = manager
label = getattr(manager, "label", None) or condition_config.display_label()
metadata = getattr(manager, "metadata", {}) or {}
edge_link.condition = label
edge_link.condition_metadata = metadata
edge_link.condition_type = condition_config.type
process_config = edge_link.process_config
if process_config:
try:
processor = build_edge_payload_processor(process_config, processor_context)
except Exception as exc: # pragma: no cover
error_msg = (
f"Failed to prepare processor '{process_config.display_label()}': {exc}"
)
self.log_manager.error(error_msg)
logger = get_server_logger()
logger.log_exception(exc, error_msg, processor_type=process_config.type)
raise WorkflowExecutionError(error_msg) from exc
edge_link.payload_processor = processor
edge_link.process_type = process_config.type
edge_link.process_metadata = getattr(processor, "metadata", {}) or {}
processor_label = getattr(processor, "label", None)
if processor_label:
edge_link.config["process_label"] = processor_label
else:
edge_link.payload_processor = None
edge_link.process_metadata = {}
edge_link.process_type = None
def _process_edge_output(
self,
edge_link: EdgeLink,
source_result: Message,
from_node: Node
) -> None:
"""Perform edge instantiation behavior.
Edges with dynamic configuration still pass messages normally to the target
node's input queue. Dynamic execution happens when the target node executes.
"""
# All edges (including dynamic ones) use standard processing to pass messages
# Dynamic execution will happen in _execute_node when the target node runs
# Standard edge processing (no dynamic config)
manager = edge_link.condition_manager
if manager is None:
raise WorkflowExecutionError(
f"Edge {from_node.id}->{edge_link.target.id} is missing a condition manager"
)
try:
manager.process(
edge_link,
source_result,
from_node,
self.log_manager,
)
except Exception as exc: # pragma: no cover - defensive logging
error_msg = (
f"Edge manager failed for {from_node.id} -> {edge_link.target.id}: {exc}"
)
self.log_manager.error(
error_msg,
details={
"condition_type": edge_link.condition_type,
"condition_metadata": edge_link.condition_metadata,
},
)
logger = get_server_logger()
logger.log_exception(
exc,
error_msg,
condition_type=edge_link.condition_type,
condition_metadata=edge_link.condition_metadata,
)
raise WorkflowExecutionError(error_msg) from exc
def _get_dynamic_config_for_node(self, node: Node):
"""Get the dynamic configuration for a node from its incoming edges.
If multiple incoming edges have dynamic config, they must be identical
(same type and parameters). Otherwise raises an error.
Returns the dynamic config if found, or None.
"""
from entity.configs.edge.dynamic_edge_config import DynamicEdgeConfig
found_configs = [] # List of (source_node_id, dynamic_config)
for predecessor in node.predecessors:
for edge_link in predecessor.iter_outgoing_edges():
if edge_link.target is node and edge_link.dynamic_config is not None:
found_configs.append((predecessor.id, edge_link.dynamic_config))
if not found_configs:
return None
if len(found_configs) == 1:
return found_configs[0][1]
# Multiple dynamic configs found - verify they are consistent
first_source, first_config = found_configs[0]
for source_id, config in found_configs[1:]:
# Check type consistency
if config.type != first_config.type:
raise WorkflowExecutionError(
f"Node '{node.id}' has inconsistent dynamic configurations on incoming edges: "
f"edge from '{first_source}' has type '{first_config.type}', "
f"but edge from '{source_id}' has type '{config.type}'. "
f"All dynamic edges to the same node must use the same configuration."
)
# Check split config consistency
if (config.split.type != first_config.split.type or
config.split.pattern != first_config.split.pattern or
config.split.json_path != first_config.split.json_path):
raise WorkflowExecutionError(
f"Node '{node.id}' has inconsistent split configurations on incoming edges: "
f"edges from '{first_source}' and '{source_id}' have different split settings. "
f"All dynamic edges to the same node must use the same configuration."
)
# Check mode-specific config consistency
if config.max_parallel != first_config.max_parallel:
raise WorkflowExecutionError(
f"Node '{node.id}' has inconsistent max_parallel on incoming edges: "
f"edge from '{first_source}' has max_parallel={first_config.max_parallel}, "
f"but edge from '{source_id}' has max_parallel={config.max_parallel}."
)
if config.type == "tree" and config.group_size != first_config.group_size:
raise WorkflowExecutionError(
f"Node '{node.id}' has inconsistent group_size on incoming edges: "
f"edge from '{first_source}' has group_size={first_config.group_size}, "
f"but edge from '{source_id}' has group_size={config.group_size}."
)
return first_config
def _execute_with_dynamic_config(
self,
node: Node,
inputs: List[Message],
dynamic_config,
) -> List[Message]:
"""Execute a node with dynamic configuration from incoming edges.
Args:
node: Target node to execute
inputs: All input messages collected for this node
dynamic_config: Dynamic configuration from the incoming edge
Returns:
Output messages from dynamic execution
"""
# Separate inputs: dynamic edge inputs vs static (non-dynamic) edge inputs
# Dynamic edge inputs will be split, static inputs will be replicated to all units
dynamic_inputs: List[Message] = []
static_inputs: List[Message] = []
for msg in inputs:
if msg.metadata.get("_from_dynamic_edge"):
dynamic_inputs.append(msg)
else:
static_inputs.append(msg)
self.log_manager.info(
f"Executing node {node.id} with edge dynamic config ({dynamic_config.type} mode): "
f"{len(dynamic_inputs)} dynamic inputs, {len(static_inputs)} static inputs"
)
# Create node executor function
def node_executor_func(n: Node, inp: List[Message]) -> List[Message]:
return self._process_result(n, inp)
# Execute with dynamic edge executor
dynamic_executor = DynamicEdgeExecutor(self.log_manager, node_executor_func)
# Pass dynamic inputs for splitting, static inputs for replication
return dynamic_executor.execute_from_inputs(
node, dynamic_inputs, dynamic_config, static_inputs=static_inputs
)
def _execute_node(self, node: Node) -> None:
"""Execute a single node."""
self._raise_if_cancelled()
with self.resource_manager.guard_node(node):
input_results = node.input
# Clear incoming triggers so future iterations wait for fresh signals
node.reset_triggers()
serialized_inputs = [message.to_dict(include_data=False) for message in input_results]
# Record node start
self.log_manager.record_node_start(node.id, serialized_inputs, node.node_type, {
"input_count": len(input_results),
"predecessors": [p.id for p in node.predecessors],
"successors": [s.id for s in node.successors]
})
self.log_manager.debug(f"Processing {len(input_results)} inputs together for node {node.id}")
# Check if any incoming edge has dynamic configuration
dynamic_config = self._get_dynamic_config_for_node(node)
# Process all inputs together in a single executor call
with self.log_manager.node_timer(node.id):
if dynamic_config is not None:
raw_outputs = self._execute_with_dynamic_config(node, input_results, dynamic_config)
else:
raw_outputs = self._process_result(node, input_results)
# Process all output messages
output_messages: List[Message] = []
for raw_output in raw_outputs:
msg = self._ensure_source_output(raw_output, node.id)
node.append_output(msg)
output_messages.append(msg)
# Use first output for context trace handling (backward compat)
unified_output = output_messages[0] if output_messages else None
context_trace_payload = None
context_restored = False
if unified_output is not None and isinstance(unified_output.metadata, dict):
context_trace_payload = unified_output.metadata.get("context_trace")
if node.context_window != 0 and context_trace_payload:
context_restored = self._restore_context_trace(node, context_trace_payload)
if node.context_window != -1:
preserved_inputs = node.clear_input(preserve_kept=True, context_window=node.context_window)
if preserved_inputs:
self.log_manager.debug(
f"Node {node.id} cleaned up its input context after execution (preserved {preserved_inputs} keep-marked inputs)"
)
else:
self.log_manager.debug(
f"Node {node.id} cleaned up its input context after execution"
)
if output_messages:
self.log_manager.debug(
f"Node {node.id} processed {len(input_results)} inputs into {len(output_messages)} output(s)"
)
else:
self.log_manager.debug(
f"Node {node.id} produced no output; downstream edges suppressed"
)
# Record node end
output_text = ""
if output_messages:
if len(output_messages) == 1:
output_text = unified_output.text_content()
else:
for idx, msg in enumerate(output_messages):
output_text += f"===== OUTPUT {idx} =====\n\n" + msg.text_content() + "\n\n"
output_role = unified_output.role.value
output_source = unified_output.metadata.get("source")
else:
output_text = ""
output_role = "none"
output_source = None
self.log_manager.record_node_end(node.id, output_text if node.log_output else "", {
"output_size": len(output_text),
"output_count": len(output_messages),
"output_role": output_role,
"output_source": output_source
})
# Pass results to successor nodes via edges
# For each output message, process all edges
for output_msg in output_messages:
for edge_link in node.iter_outgoing_edges():
self._process_edge_output(edge_link, output_msg, node)
if output_messages and node.context_window != 0 and not context_restored:
# Use first output for pseudo edge
pseudo_condition = EdgeConditionConfig.from_dict("true", path=f"{node.path}.pseudo_edge")
pseudo_link = EdgeLink(target=node, trigger=False)
pseudo_link.condition_config = pseudo_condition
pseudo_context = ConditionFactoryContext(
function_manager=self.function_manager,
log_manager=self.log_manager,
)
pseudo_link.condition_manager = build_edge_condition_manager(pseudo_condition, pseudo_context, self._get_execution_context())
pseudo_link.condition = pseudo_condition.display_label()
pseudo_link.condition_type = pseudo_condition.type
for output_msg in output_messages:
self._process_edge_output(pseudo_link, output_msg, node)
def _process_result(self, node: Node, input_payload: List[Message]) -> List[Message]:
"""Process a single input result using strategy pattern executors.
This method delegates to specific node executors based on node type.
Returns a list of messages (maybe empty if node suppresses output).
"""
if not self.node_executors:
raise RuntimeError("Node executors not initialized. Call _build_memories_and_thinking() first.")
if node.type not in self.node_executors:
raise ValueError(f"Unsupported node type: {node.type}")
executor = self.node_executors[node.type]
hook = self.runtime_context.workspace_hook
workspace = self.runtime_context.code_workspace
if hook:
try:
hook.before_node(node, workspace)
except Exception:
self.log_manager.warning("workspace hook before_node failed for %s", node.id)
success = False
try:
result = executor.execute(node, input_payload)
success = True
return result
finally:
if hook:
try:
hook.after_node(node, workspace, success=success)
except Exception:
self.log_manager.warning("workspace hook after_node failed for %s", node.id)
def _collect_all_outputs(self) -> None:
"""Collect final outputs from all nodes, especially sink nodes."""
all_outputs = {}
# For majority voting, we might want to collect differently
if self.graph.is_majority_voting:
# In majority voting mode, collect all outputs and the final majority result
for node_id, node in self.graph.nodes.items():
if node.output:
node_output = {
"node_id": node_id,
"node_type": node.node_type,
"predecessors_num": len(node.predecessors),
"successors_num": len(node.successors),
"results": [self._serialize_output_payload(item) for item in node.output]
}
all_outputs[f"node_{node_id}"] = node_output
# Add the majority result
if hasattr(self, 'majority_result'):
all_outputs["majority_result"] = self.majority_result
else:
# Collect outputs from all nodes normally
for node_id, node in self.graph.nodes.items():
if node.output:
node_output = {
"node_id": node_id,
"node_type": node.node_type,
"predecessors_num": len(node.predecessors),
"successors_num": len(node.successors),
"results": [self._serialize_output_payload(item) for item in node.output]
}
all_outputs[f"node_{node_id}"] = node_output
# Add graph summary
all_outputs["graph_summary"] = {
"total_nodes": len(self.graph.nodes),
"total_edges": len(self.graph.edges),
"total_transmissions": len([k for k in self.outputs.keys() if "->" in k]),
"layers": len(self.graph.layers),
"execution_completed": True,
"is_majority_voting": self.graph.is_majority_voting
}
self.outputs.update(all_outputs)
def get_final_output(self) -> str:
final_message = self.get_final_output_message()
return final_message.text_content() if final_message else ""
def get_final_output_message(self) -> Message | None:
if self.graph.is_majority_voting:
if self.majority_result is None:
return None
if isinstance(self.majority_result, Message):
return self.majority_result.clone()
return self._create_message(MessageRole.ASSISTANT, str(self.majority_result), "MAJORITY_VOTE")
final_node = self._get_final_node()
if not final_node:
return None
if final_node.output:
value = final_node.output[-1]
if isinstance(value, Message):
return value.clone()
return self._create_message(MessageRole.ASSISTANT, str(value), final_node.id)
return None
def get_final_output_messages(self) -> List[Message]:
"""Return all messages from the final node."""
if self.graph.is_majority_voting:
msg = self.get_final_output_message()
return [msg] if msg else []
final_node = self._get_final_node()
if not final_node:
return []
results = []
for value in final_node.output:
if isinstance(value, Message):
results.append(value.clone())
else:
results.append(self._create_message(MessageRole.ASSISTANT, str(value), final_node.id))
return results
def _get_final_node(self) -> Node:
"""Return the explicitly configured end node, or sink node as fallback."""
end_node_ids = self.graph.config.definition.end_nodes
if end_node_ids:
for end_node_id in end_node_ids:
if end_node_id in self.graph.nodes:
node = self.graph.nodes[end_node_id]
# Check if node has output
if node.output:
return node
# Fallback to default behavior - return sink node
sink_node = [node for node in self.graph.nodes.values() if not node.successors]
return sink_node[0] if sink_node else None
def _restore_context_trace(self, node: Node, trace_payload: Any) -> bool:
if not isinstance(trace_payload, list):
return False
restored = 0
for entry in trace_payload:
if not isinstance(entry, dict):
continue
try:
message = Message.from_dict(entry)
if message.role not in [MessageRole.USER, MessageRole.ASSISTANT]:
continue
except Exception as exc:
self.log_manager.warning(
f"Failed to deserialize context trace for node {node.id}: {exc}"
)
continue
node.append_input(self._ensure_source(message, node.id))
restored += 1
if restored:
self.log_manager.debug(
f"Node {node.id} preserved {restored} messages from its tool execution trace"
)
return restored > 0
def _payload_to_text(self, payload: Any) -> str:
if isinstance(payload, Message):
return payload.text_content()
if payload is None:
return ""
return str(payload)
def _serialize_output_payload(self, payload: Any) -> Any:
if isinstance(payload, Message):
return {"type": "message", "payload": payload.to_dict(include_data=False)}
return {"type": "text", "payload": str(payload)}
def _normalize_task_input(self, raw_input: Any) -> List[Message]:
if isinstance(raw_input, list):
messages: List[Message] = []
for item in raw_input:
if isinstance(item, Message):
messages.append(self._ensure_source(item, "TASK"))
elif isinstance(item, str):
messages.append(self._create_message(MessageRole.USER, item, "TASK"))
return messages or [self._create_message(MessageRole.USER, "", "TASK")]
if isinstance(raw_input, Message):
return [self._ensure_source(raw_input, "TASK")]
return [self._create_message(MessageRole.USER, str(raw_input), "TASK")]
def _ensure_source(self, message: Message, default_source: str) -> Message:
cloned = message.clone()
metadata = dict(cloned.metadata)
metadata.setdefault("source", default_source)
cloned.metadata = metadata
return cloned
def _create_message(self, role: MessageRole, content: str, source: str) -> Message:
return Message(role=role, content=content, metadata={"source": source})
def _ensure_source_output(self, message: Any, node_id: str) -> Message:
if not isinstance(message, Message):
return self._create_message(MessageRole.ASSISTANT, str(message), node_id)
cloned = message.clone()
metadata = dict(message.metadata)
metadata.setdefault("source", node_id)
cloned.metadata = metadata
return cloned
+155
View File
@@ -0,0 +1,155 @@
"""Runtime context for workflow graphs.
This module stores execution-time state and business logic for graphs.
"""
from datetime import datetime
from typing import Any, Dict, List
import yaml
from entity.configs import Node
from entity.graph_config import GraphConfig
class GraphContext:
"""Runtime context for a workflow graph (state + business logic).
Differences from ``GraphConfig``:
- ``GraphConfig`` is immutable configuration data.
- ``GraphContext`` is mutable runtime state with dynamic execution data.
Attributes:
config: Graph configuration
nodes: Mapping of ``node_id`` to ``Node``
edges: List of edges
layers: Topological layer layout
outputs: Node outputs captured during execution
topology: Topological ordering list
subgraphs: Mapping of ``node_id`` to nested ``GraphContext``
has_cycles: Whether the graph contains cycles
cycle_execution_order: Execution order for cycles
directory: Output directory for artifacts
depth: Graph depth
"""
def __init__(self, config: GraphConfig) -> None:
"""Initialize the graph context.
Args:
config: Graph configuration
"""
self.config = config
self.vars: Dict[str, Any] = dict(config.vars)
# Graph structure
self.nodes: Dict[str, Node] = {}
self.edges: List[Dict[str, Any]] = []
self.layers: List[List[str]] = []
self.topology: List[str] = []
self.depth: int = 0
self.start_nodes: List[str] = []
self.explicit_start_nodes: List[str] = []
# Runtime state
self.outputs: Dict[str, str] = {}
self.subgraphs: Dict[str, "GraphContext"] = {}
# Cycle support
self.has_cycles: bool = False
self.cycle_execution_order: List[Dict[str, Any]] = []
# Output directory
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
fixed_output_dir = bool(config.metadata.get("fixed_output_dir"))
if fixed_output_dir or "session_" in config.name:
self.directory = config.output_root / config.name
else:
self.directory = config.output_root / f"{config.name}_{timestamp}"
self.directory.mkdir(parents=True, exist_ok=True)
# Voting mode flag
self.is_majority_voting: bool = config.is_majority_voting
@property
def name(self) -> str:
"""Return the project name."""
return self.config.name
@property
def log_level(self):
"""Return the configured log level."""
return self.config.log_level
@property
def metadata(self) -> Dict[str, Any]:
"""Return graph metadata."""
return self.config.metadata
@metadata.setter
def metadata(self, value: Dict[str, Any]) -> None:
"""Set graph metadata."""
self.config.metadata = value
def record(self, outputs: Dict[str, Any]) -> None:
"""Persist execution results to disk.
Args:
outputs: Mapping of node outputs
"""
self.outputs = outputs
# self.directory.mkdir(parents=True, exist_ok=True)
# Persist node outputs
outputs_path = self.directory / "node_outputs.yaml"
if self.outputs:
with outputs_path.open("w", encoding="utf-8") as handle:
yaml.dump(self.outputs, handle, allow_unicode=True, sort_keys=False)
# Persist workflow summary
summary = {
"project": self.config.name,
"organization": self.config.get_organization(),
"design_path": self.config.get_source_path(),
"metadata": self.config.metadata,
}
summary_path = self.directory / "workflow_summary.yaml"
with summary_path.open("w", encoding="utf-8") as handle:
yaml.dump(summary, handle, allow_unicode=True, sort_keys=False)
def final_message(self) -> str:
"""Build the final completion string.
Returns:
Completion message text
"""
if not self.outputs:
return "Workflow finished with no outputs."
sink_nodes = [node_id for node_id, node in self.nodes.items() if not node.successors]
return (
f"Workflow finished with {len(self.outputs)} node outputs"
f" ({len(sink_nodes)} terminal nodes)."
)
def get_sink_nodes(self) -> List[Node]:
"""Return all leaf nodes (nodes without successors)."""
return [node for node in self.nodes.values() if not node.successors]
def get_source_nodes(self) -> List[Node]:
"""Return all source nodes (nodes without predecessors)."""
return [node for node in self.nodes.values() if not node.predecessors]
def to_dict(self) -> Dict[str, Any]:
"""Convert the graph context to a dictionary."""
return {
"config": self.config.to_dict(),
"nodes": {node_id: node.to_dict() for node_id, node in self.nodes.items()},
"edges": list(self.edges),
"layers": list(self.layers),
"topology": list(self.topology),
"depth": self.depth,
"has_cycles": self.has_cycles,
"start_nodes": list(self.start_nodes),
"explicit_start_nodes": list(self.explicit_start_nodes),
"outputs": dict(self.outputs),
}
+350
View File
@@ -0,0 +1,350 @@
"""Graph management and construction utilities for workflow graphs."""
from typing import Dict, List, Set, Any
import copy
from entity.configs import ConfigError, SubgraphConfig
from entity.configs.edge.edge_condition import EdgeConditionConfig
from entity.configs.base import extend_path
from entity.configs.node.subgraph import SubgraphFileConfig, SubgraphInlineConfig
from workflow.cycle_manager import CycleManager
from workflow.subgraph_loader import load_subgraph_config
from workflow.topology_builder import GraphTopologyBuilder
from utils.env_loader import build_env_var_map
from utils.vars_resolver import resolve_mapping_with_vars
from workflow.graph_context import GraphContext
class GraphManager:
"""Manages graph construction, cycle detection, and execution order determination."""
def __init__(self, graph: "GraphContext") -> None:
"""Initialize GraphManager with a GraphContext instance."""
self.graph = graph
self.cycle_manager = CycleManager()
def build_graph_structure(self) -> None:
"""Build the complete graph structure including nodes, edges, and layers."""
self._instantiate_nodes()
self._initiate_edges()
self._determine_start_nodes()
self._warn_on_untriggerable_nodes()
self._build_topology_and_metadata()
def _instantiate_nodes(self) -> None:
"""Instantiate all nodes from configuration."""
self.graph.nodes.clear()
for node_def in self.graph.config.get_node_definitions():
node_id = node_def.id
if node_id in self.graph.nodes:
print(f"Duplicated node id detected: {node_id}")
continue
node_instance = copy.deepcopy(node_def)
node_instance.predecessors = []
node_instance.successors = []
node_instance._outgoing_edges = []
node_instance.vars = dict(self.graph.vars)
self.graph.nodes[node_id] = node_instance
if node_instance.node_type == "subgraph":
self._build_subgraph(node_id)
def _build_subgraph(self, node_id: str) -> None:
"""Build a subgraph for the given node ID."""
from entity.graph_config import GraphConfig
from workflow.graph_context import GraphContext
subgraph_config_data = self.graph.nodes[node_id].as_config(SubgraphConfig)
if not subgraph_config_data:
return
parent_source = self.graph.config.get_source_path()
subgraph_vars: Dict[str, Any] = {}
if subgraph_config_data.type == "config":
inline_cfg = subgraph_config_data.as_config(SubgraphInlineConfig)
if not inline_cfg:
raise ConfigError(
f"Inline subgraph configuration missing for node '{node_id}'",
subgraph_config_data.path,
)
config_payload = copy.deepcopy(inline_cfg.graph)
source_path = parent_source
elif subgraph_config_data.type == "file":
file_cfg = subgraph_config_data.as_config(SubgraphFileConfig)
if not file_cfg:
raise ConfigError(
f"File subgraph configuration missing for node '{node_id}'",
subgraph_config_data.path,
)
config_payload, subgraph_vars, source_path = load_subgraph_config(
file_cfg.file_path,
parent_source=parent_source,
)
else:
raise ConfigError(
f"Unsupported subgraph configuration on node '{node_id}'",
subgraph_config_data.path,
)
combined_vars = dict(self.graph.config.vars)
combined_vars.update(subgraph_vars)
resolve_mapping_with_vars(
config_payload,
env_lookup=build_env_var_map(),
vars_map=combined_vars,
path=f"subgraph[{node_id}]",
)
if config_payload.get("log_level", None) is None:
config_payload["log_level"] = self.graph.log_level.value
subgraph_config = GraphConfig.from_dict(
config=config_payload,
name=f"{self.graph.name}_{node_id}_subgraph",
output_root=self.graph.config.output_root,
source_path=source_path,
vars=combined_vars,
)
subgraph = GraphContext(config=subgraph_config)
subgraph_manager = GraphManager(subgraph)
subgraph_manager.build_graph_structure()
self.graph.subgraphs[node_id] = subgraph
def _initiate_edges(self) -> None:
"""Initialize edges and determine layers or cycle execution order."""
# For majority voting mode, there are no edges by design
if self.graph.is_majority_voting:
print("Majority voting mode detected - skipping edge initialization")
self.graph.edges = []
# For majority voting, all nodes are independent and can be executed in parallel
# Create a single layer with all nodes
all_node_ids = list(self.graph.nodes.keys())
self.graph.layers = [all_node_ids]
return
self.graph.edges = []
for edge_config in self.graph.config.get_edge_definitions():
src = edge_config.source
dst = edge_config.target
if src not in self.graph.nodes or dst not in self.graph.nodes:
print(f"Edge references unknown node: {src}->{dst}")
continue
condition_config = edge_config.condition
if condition_config is None:
condition_config = EdgeConditionConfig.from_dict("true", path=extend_path(edge_config.path, "condition"))
condition_value = condition_config.to_external_value()
process_config = edge_config.process
process_value = process_config.to_external_value() if process_config else None
dynamic_config = edge_config.dynamic
payload = {
"trigger": edge_config.trigger,
"condition": condition_value,
"condition_config": condition_config,
"condition_label": condition_config.display_label(),
"condition_type": condition_config.type,
"carry_data": edge_config.carry_data,
"keep_message": edge_config.keep_message,
"clear_context": edge_config.clear_context,
"clear_kept_context": edge_config.clear_kept_context,
"process_config": process_config,
"process": process_value,
"process_type": process_config.type if process_config else None,
"dynamic_config": dynamic_config,
}
self.graph.nodes[src].add_successor(self.graph.nodes[dst], payload)
self.graph.nodes[dst].add_predecessor(self.graph.nodes[src])
self.graph.edges.append({
"from": src,
"to": dst,
"trigger": edge_config.trigger,
"condition": condition_value,
"condition_type": condition_config.type,
"carry_data": edge_config.carry_data,
"keep_message": edge_config.keep_message,
"clear_context": edge_config.clear_context,
"clear_kept_context": edge_config.clear_kept_context,
"process": process_value,
"process_type": process_config.type if process_config else None,
"dynamic": dynamic_config is not None,
})
# Check for cycles and build appropriate execution structure
cycles = self._detect_cycles()
self.graph.has_cycles = len(cycles) > 0
if self.graph.has_cycles:
print(f"Detected {len(cycles)} cycle(s) in the workflow graph.")
self.graph.layers = self._build_cycle_execution_order(cycles)
else:
self.graph.layers = self._build_dag_layers()
def _detect_cycles(self) -> List[Set[str]]:
"""Detect cycles in the graph using GraphTopologyBuilder."""
return GraphTopologyBuilder.detect_cycles(self.graph.nodes)
def _build_dag_layers(self) -> List[List[str]]:
"""Build layers for DAG (Directed Acyclic Graph) using GraphTopologyBuilder."""
layers_with_items = GraphTopologyBuilder.build_dag_layers(self.graph.nodes)
# Convert format to be compatible with existing code
layers = [
[item["node_id"] for item in layer]
for layer in layers_with_items
]
print(f"layers: {layers}")
if len(set(node_id for layer in layers for node_id in layer)) != len(self.graph.nodes):
print("Detected a cycle in the workflow graph; a DAG is required.")
return layers
def _build_cycle_execution_order(self, cycles: List[Set[str]]) -> List[List[str]]:
"""Build execution order for graphs with cycles using super-node abstraction and GraphTopologyBuilder."""
# Initialize cycle manager
self.cycle_manager.initialize_cycles(cycles, self.graph.nodes)
# Use GraphTopologyBuilder to create super-node graph
super_node_graph = GraphTopologyBuilder.create_super_node_graph(
self.graph.nodes,
self.graph.edges,
cycles
)
# Use GraphTopologyBuilder for topological sorting
execution_order = GraphTopologyBuilder.topological_sort_super_nodes(
super_node_graph,
cycles
)
# Enrich execution_order with entry_nodes and exit_edges from cycle_manager
for layer in execution_order:
for item in layer:
if item["type"] == "cycle":
cycle_id = item["cycle_id"]
cycle_info = self.cycle_manager.cycles[cycle_id]
item["entry_nodes"] = list(cycle_info.entry_nodes)
item["exit_edges"] = cycle_info.exit_edges
self.graph.cycle_execution_order = execution_order
# Return a simplified layer structure for compatibility
return [["__CYCLE_AWARE__"]] # Special marker for cycle-aware execution
def _build_topology_and_metadata(self) -> None:
"""Build topology and metadata for the graph."""
self.graph.topology = [node_id for layer in self.graph.layers for node_id in layer]
self.graph.depth = len(self.graph.layers) - 1 if self.graph.layers else 0
self.graph.metadata = self._build_metadata()
def _build_metadata(self) -> Dict[str, Any]:
"""Build metadata for the graph."""
graph_def = self.graph.config.definition
catalog: Dict[str, Any] = {}
for node_id, node in self.graph.nodes.items():
catalog[node_id] = {
"type": node.node_type,
"description": node.description,
"model_name": node.model_name,
"role": node.role,
"tools": node.tools,
"memories": node.memories,
"params": node.params,
}
return {
"design_id": graph_def.id,
"node_count": len(self.graph.nodes),
"edge_count": len(self.graph.edges),
"start": list(self.graph.start_nodes),
"end": graph_def.end_nodes,
"catalog": catalog,
"topology": self.graph.topology,
"layers": self.graph.layers,
}
def _determine_start_nodes(self) -> None:
"""Determine the effective set of start nodes (explicit only)."""
definition = self.graph.config.definition
explicit_ordered = list(definition.start_nodes)
explicit_set = set(explicit_ordered)
# if explicit_ordered and not self.graph.has_cycles:
# raise ConfigError(
# "start nodes can only be specified for graphs that contain cycles",
# extend_path(definition.path, "start"),
# )
if explicit_set:
cycle_path = extend_path(definition.path, "start")
for node_id in explicit_ordered:
if node_id not in self.graph.nodes:
raise ConfigError(
f"start node '{node_id}' not defined in nodes",
cycle_path,
)
cycle_id = self.cycle_manager.node_to_cycle.get(node_id)
if cycle_id is None:
continue
cycle_info = self.cycle_manager.cycles.get(cycle_id)
if cycle_info is None:
raise ConfigError(
f"cycle data missing for start node '{node_id}'",
cycle_path,
)
if cycle_info.configured_entry_node and cycle_info.configured_entry_node != node_id:
raise ConfigError(
f"cycle '{cycle_id}' already has start node '{cycle_info.configured_entry_node}'",
cycle_path,
)
cycle_info.configured_entry_node = node_id
if not explicit_ordered:
raise ConfigError(
"Unable to determine a start node for this graph. Configure at least one Start Node via Configure Graph > Advanced Settings > Start Node > input node ID.",
extend_path(definition.path, "start"),
)
self.graph.start_nodes = explicit_ordered
self.graph.explicit_start_nodes = explicit_ordered
def _warn_on_untriggerable_nodes(self) -> None:
"""Emit warnings for nodes that cannot be triggered by any predecessor."""
start_nodes = set(self.graph.start_nodes or [])
for node_id, node in self.graph.nodes.items():
if not node.predecessors:
continue
if node_id in start_nodes:
continue
has_triggerable_edge = False
for predecessor in node.predecessors:
for edge_link in predecessor.iter_outgoing_edges():
if edge_link.target is node and edge_link.trigger:
has_triggerable_edge = True
break
if has_triggerable_edge:
break
if not has_triggerable_edge:
print(
f"Warning: node '{node_id}' has no triggerable incoming edges and will never execute."
)
def get_cycle_manager(self) -> CycleManager:
"""Get the cycle manager instance."""
return self.cycle_manager
def build_graph(self) -> None:
"""Build graph structure only (no memory/thinking initialization)."""
self.build_graph_structure()
+5
View File
@@ -0,0 +1,5 @@
"""Hook implementations for workflow runtime."""
from .workspace_artifact import WorkspaceArtifactHook, WorkspaceArtifact
__all__ = ["WorkspaceArtifactHook", "WorkspaceArtifact"]
+274
View File
@@ -0,0 +1,274 @@
"""Hook that scans a node workspace for newly created files."""
import hashlib
import logging
import mimetypes
import os
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Dict, List, Optional, Sequence, Set, Tuple
from entity.configs import Node
from entity.messages import MessageBlockType
from utils.attachments import AttachmentRecord, AttachmentStore
from utils.human_prompt import PromptChannel
@dataclass
class WorkspaceArtifact:
"""Represents a file artifact detected by the workspace hook."""
node_id: str
attachment_id: str
file_name: str
relative_path: str
absolute_path: str
mime_type: Optional[str]
size: Optional[int]
sha256: Optional[str]
data_uri: Optional[str]
created_at: float
change_type: str
extra: Dict[str, object]
@dataclass
class _FileSignature:
sha256: str
size: int
@dataclass
class _TrackedEntry:
sha256: str
attachment_id: str
absolute_path: str
mime_type: Optional[str]
size: Optional[int]
data_uri: Optional[str]
class WorkspaceArtifactHook:
"""Detects workspace file changes for selected node types."""
def __init__(
self,
*,
attachment_store: AttachmentStore,
emit_callback: Callable[[List[WorkspaceArtifact]], None],
node_types: Optional[Sequence[str]] = None,
exclude_dirs: Optional[Sequence[str]] = None,
max_files_scanned: int = 500,
max_bytes_scanned: int = 500 * 1024 * 1024,
prompt_channel: Optional[PromptChannel] = None,
) -> None:
self.attachment_store = attachment_store
self.emit_callback = emit_callback
self.node_types: Set[str] = set(node_types or {"python", "agent"})
self.exclude_dirs = set(exclude_dirs or {"attachments", "__pycache__"})
self.max_files_scanned = max_files_scanned
self.max_bytes_scanned = max_bytes_scanned
self.logger = logging.getLogger(__name__)
self._snapshots: Dict[str, Dict[str, _FileSignature]] = {}
self._last_emitted: Dict[str, _TrackedEntry] = {}
self.prompt_channel = prompt_channel
def can_handle(self, node: Node) -> bool:
return node.node_type in self.node_types
def get_prompt_channel(self) -> Optional[PromptChannel]:
return self.prompt_channel
def before_node(self, node: Node, workspace: Path) -> None:
if not self.can_handle(node):
return
snapshot, _ = self._snapshot(workspace)
self._snapshots[node.id] = snapshot
def after_node(
self,
node: Node,
workspace: Path,
*,
success: bool,
) -> None:
if not success or not self.can_handle(node):
self._snapshots.pop(node.id, None)
return
before = self._snapshots.pop(node.id, {})
after, truncated = self._snapshot(workspace)
if not after and not self._last_emitted:
return
changed_paths = [
Path(path_str)
for path_str, signature in after.items()
if path_str not in before or before[path_str].sha256 != signature.sha256
]
artifacts: List[WorkspaceArtifact] = []
for relative_path in changed_paths:
signature = after[str(relative_path)]
full_path = workspace / relative_path
if not full_path.exists() or not full_path.is_file():
continue
try:
tracked = self._last_emitted.get(str(relative_path))
change_type = "created" if tracked is None else "updated"
record = self._register_artifact(
full_path,
relative_path,
node,
attachment_id=tracked.attachment_id if tracked else None,
)
except Exception as exc:
self.logger.warning(
"Failed to register artifact %s for node %s: %s",
relative_path,
node.id,
exc,
)
continue
artifacts.append(
self._to_artifact(
record,
node,
relative_path,
full_path,
change_type=change_type,
)
)
self._last_emitted[str(relative_path)] = _TrackedEntry(
sha256=signature.sha256,
attachment_id=record.ref.attachment_id or "",
absolute_path=str(full_path),
mime_type=record.ref.mime_type,
size=record.ref.size,
data_uri=record.ref.data_uri,
)
if not truncated:
deleted_paths = [
relative_path
for relative_path in list(self._last_emitted.keys())
if relative_path not in after
]
for relative_path in deleted_paths:
tracked = self._last_emitted.pop(relative_path, None)
if not tracked:
continue
artifacts.append(
WorkspaceArtifact(
node_id=node.id,
attachment_id=tracked.attachment_id,
file_name=Path(relative_path).name,
relative_path=relative_path,
absolute_path=tracked.absolute_path,
mime_type=tracked.mime_type,
size=tracked.size,
sha256=tracked.sha256,
data_uri=tracked.data_uri,
created_at=time.time(),
change_type="deleted",
extra={
"hook": "workspace_scan",
"relative_path": relative_path,
},
)
)
if artifacts:
self.emit_callback(artifacts)
def _snapshot(self, workspace: Path) -> Tuple[Dict[str, _FileSignature], bool]:
entries: Dict[str, _FileSignature] = {}
total_bytes = 0
file_count = 0
for root, dirs, files in os.walk(workspace):
rel_root = Path(root).relative_to(workspace)
dirs[:] = [d for d in dirs if not self._is_excluded(rel_root / d)]
for filename in files:
rel_path = rel_root / filename
if self._is_excluded(rel_path):
continue
full_path = Path(root) / filename
try:
stat = full_path.stat()
sha256 = self._hash_file(full_path)
except OSError:
continue
file_count += 1
total_bytes += stat.st_size
entries[str(rel_path)] = _FileSignature(sha256=sha256, size=stat.st_size)
if file_count >= self.max_files_scanned or total_bytes >= self.max_bytes_scanned:
self.logger.warning(
"Workspace scan truncated (files=%s total_bytes=%s) for session %s",
file_count,
total_bytes,
)
return entries, True
return entries, False
def _is_excluded(self, rel_path: Path) -> bool:
if not rel_path.parts:
return False
return rel_path.parts[0] in self.exclude_dirs
def _register_artifact(
self,
full_path: Path,
relative_path: Path,
node: Node,
*,
attachment_id: Optional[str] = None,
) -> AttachmentRecord:
mime_type = mimetypes.guess_type(relative_path.name)[0] or "application/octet-stream"
return self.attachment_store.register_file(
full_path,
kind=MessageBlockType.from_mime_type(mime_type),
mime_type=mime_type,
display_name=full_path.name,
copy_file=False,
persist=True,
deduplicate=False,
attachment_id=attachment_id,
extra={
"node_id": node.id,
"relative_path": str(relative_path),
"hook": "workspace_scan",
},
)
def _to_artifact(
self,
record: AttachmentRecord,
node: Node,
relative_path: Path,
full_path: Path,
*,
change_type: str,
) -> WorkspaceArtifact:
ref = record.ref
return WorkspaceArtifact(
node_id=node.id,
attachment_id=ref.attachment_id or "",
file_name=ref.name or full_path.name,
relative_path=str(relative_path),
absolute_path=str(full_path),
mime_type=ref.mime_type,
size=ref.size,
sha256=ref.sha256,
data_uri=ref.data_uri,
created_at=time.time(),
change_type=change_type,
extra=dict(record.extra),
)
def _hash_file(self, path: Path) -> str:
hasher = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
hasher.update(chunk)
return hasher.hexdigest()
+19
View File
@@ -0,0 +1,19 @@
"""Runtime utilities for workflow execution."""
from .runtime_context import RuntimeContext
from .runtime_builder import RuntimeBuilder
from .execution_strategy import (
DagExecutionStrategy,
CycleExecutionStrategy,
MajorityVoteStrategy,
)
from .result_archiver import ResultArchiver
__all__ = [
"RuntimeContext",
"RuntimeBuilder",
"DagExecutionStrategy",
"CycleExecutionStrategy",
"MajorityVoteStrategy",
"ResultArchiver",
]
+149
View File
@@ -0,0 +1,149 @@
"""Execution strategies for different graph topologies."""
from collections import Counter
from typing import Callable, Dict, List, Sequence
from entity.configs import Node
from entity.messages import Message
from utils.log_manager import LogManager
from workflow.executor.dag_executor import DAGExecutor
from workflow.executor.cycle_executor import CycleExecutor
from workflow.executor.parallel_executor import ParallelExecutor
class DagExecutionStrategy:
"""Executes acyclic graphs using the DAGExecutor."""
def __init__(
self,
log_manager: LogManager,
nodes: Dict[str, Node],
layers: List[List[str]],
execute_node_func: Callable[[Node], None],
) -> None:
self.log_manager = log_manager
self.nodes = nodes
self.layers = layers
self.execute_node_func = execute_node_func
def run(self) -> None:
dag_executor = DAGExecutor(
log_manager=self.log_manager,
nodes=self.nodes,
layers=self.layers,
execute_node_func=self.execute_node_func,
)
dag_executor.execute()
class CycleExecutionStrategy:
"""Executes graphs containing cycles via CycleExecutor."""
def __init__(
self,
log_manager: LogManager,
nodes: Dict[str, Node],
cycle_execution_order: List[Dict[str, str]],
cycle_manager,
execute_node_func: Callable[[Node], None],
) -> None:
self.log_manager = log_manager
self.nodes = nodes
self.cycle_execution_order = cycle_execution_order
self.cycle_manager = cycle_manager
self.execute_node_func = execute_node_func
def run(self) -> None:
cycle_executor = CycleExecutor(
log_manager=self.log_manager,
nodes=self.nodes,
cycle_execution_order=self.cycle_execution_order,
cycle_manager=self.cycle_manager,
execute_node_func=self.execute_node_func,
)
cycle_executor.execute()
class MajorityVoteStrategy:
"""Executes graphs configured for majority voting (no edges)."""
def __init__(
self,
log_manager: LogManager,
nodes: Dict[str, Node],
initial_messages: Sequence[Message],
execute_node_func: Callable[[Node], None],
payload_to_text_func: Callable[[object], str],
) -> None:
self.log_manager = log_manager
self.nodes = nodes
self.initial_messages = initial_messages
self.execute_node_func = execute_node_func
self.payload_to_text = payload_to_text_func
def run(self) -> str:
self.log_manager.info("Executing graph with majority voting approach")
all_nodes = list(self.nodes.values())
if not all_nodes:
self.log_manager.error("No nodes to execute in majority voting mode")
return ""
for node in all_nodes:
node.clear_input()
for message in self.initial_messages:
node.append_input(message.clone())
node_ids = [node.id for node in all_nodes]
def _execute(node_id: str) -> None:
self.execute_node_func(self.nodes[node_id])
parallel_executor = ParallelExecutor(self.log_manager, self.nodes)
parallel_executor.execute_nodes_parallel(node_ids, _execute)
return self._collect_majority_result()
def _collect_majority_result(self) -> str:
node_outputs: List[Dict[str, str]] = []
for node_id, node in self.nodes.items():
if node.output:
output_text = self.payload_to_text(node.output[-1])
else:
output_text = ""
node_outputs.append(
{
"node_id": node_id,
"node_type": node.node_type,
"output": output_text,
}
)
output_values = [item["output"] for item in node_outputs]
output_counts = Counter(output_values)
non_empty_outputs = [value for value in output_values if value.strip()]
if non_empty_outputs:
output_counts = Counter(non_empty_outputs)
if not output_counts:
self.log_manager.warning("No outputs available for majority voting")
return ""
majority_output, count = output_counts.most_common(1)[0]
self.log_manager.info(
"Majority output determined",
details={"result": majority_output, "votes": count},
)
self.log_manager.info(
"All node outputs",
details={
"outputs": [
(
item["node_id"],
item["output"][:50] + "..." if len(item["output"]) > 50 else item["output"],
)
for item in node_outputs
]
},
)
return majority_output
+32
View File
@@ -0,0 +1,32 @@
"""Utilities for persisting execution artifacts."""
from utils.log_manager import LogManager
from utils.token_tracker import TokenTracker
from workflow.graph_context import GraphContext
class ResultArchiver:
"""Handles post-execution persistence (tokens, logs, metadata)."""
def __init__(
self,
graph: GraphContext,
log_manager: LogManager,
token_tracker: TokenTracker,
) -> None:
self.graph = graph
self.log_manager = log_manager
self.token_tracker = token_tracker
def export(self, final_result: str) -> None:
token_usage_path = self.graph.directory / f"token_usage_{self.graph.name}.json"
self.token_tracker.export_to_file(str(token_usage_path))
self.log_manager.record_workflow_end(
success=True,
details={
"token_usage": self.token_tracker.get_token_usage(),
"final_result": final_result,
},
)
log_file_path = self.graph.directory / "execution_logs.json"
self.log_manager.save_logs(str(log_file_path))
+58
View File
@@ -0,0 +1,58 @@
"""Builder that assembles the runtime context for workflow execution."""
from dataclasses import dataclass
from typing import Any, Dict, Optional
from runtime.node.agent import ToolManager
from utils.attachments import AttachmentStore
from utils.function_manager import EDGE_FUNCTION_DIR, EDGE_PROCESSOR_FUNCTION_DIR, get_function_manager
from utils.log_manager import LogManager
from utils.logger import WorkflowLogger
from utils.token_tracker import TokenTracker
from workflow.graph_context import GraphContext
from .runtime_context import RuntimeContext
@dataclass
class RuntimeBuilder:
"""Constructs RuntimeContext instances for GraphExecutor."""
graph: GraphContext
def build(self, logger: Optional[WorkflowLogger] = None, *, session_id: Optional[str] = None) -> RuntimeContext:
tool_manager = ToolManager()
function_manager = get_function_manager(EDGE_FUNCTION_DIR)
processor_function_manager = get_function_manager(EDGE_PROCESSOR_FUNCTION_DIR)
logger = logger or WorkflowLogger(self.graph.name, self.graph.log_level)
log_manager = LogManager(logger)
token_tracker = TokenTracker(workflow_id=self.graph.name)
code_workspace = (self.graph.directory / "code_workspace").resolve()
code_workspace.mkdir(parents=True, exist_ok=True)
attachments_dir = code_workspace / "attachments"
attachments_dir.mkdir(parents=True, exist_ok=True)
attachment_store = AttachmentStore(attachments_dir)
global_state: Dict[str, Any] = {
"graph_directory": self.graph.directory,
"vars": self.graph.config.vars,
"python_workspace_root": code_workspace,
"attachment_store": attachment_store,
}
context = RuntimeContext(
tool_manager=tool_manager,
function_manager=function_manager,
edge_processor_function_manager=processor_function_manager,
logger=logger,
log_manager=log_manager,
token_tracker=token_tracker,
attachment_store=attachment_store,
code_workspace=code_workspace,
global_state=global_state,
)
context.session_id = session_id
if session_id:
context.global_state.setdefault("session_id", session_id)
return context
+30
View File
@@ -0,0 +1,30 @@
"""Shared runtime context for workflow execution."""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Optional
from runtime.node.agent import ToolManager
from utils.function_manager import FunctionManager
from utils.logger import WorkflowLogger
from utils.log_manager import LogManager
from utils.token_tracker import TokenTracker
from utils.attachments import AttachmentStore
@dataclass
class RuntimeContext:
"""Container for runtime-wide dependencies required by GraphExecutor."""
tool_manager: ToolManager
function_manager: FunctionManager
edge_processor_function_manager: FunctionManager
logger: WorkflowLogger
log_manager: LogManager
token_tracker: TokenTracker
attachment_store: AttachmentStore
code_workspace: Path
global_state: Dict[str, Any] = field(default_factory=dict)
cycle_manager: Optional[Any] = None # Late-bound by GraphManager
session_id: Optional[str] = None
workspace_hook: Optional[Any] = None
+81
View File
@@ -0,0 +1,81 @@
"""Utilities for loading reusable subgraph YAML definitions."""
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict, List, Tuple
from entity.configs import ConfigError
from utils.io_utils import read_yaml
_REPO_ROOT = Path(__file__).resolve().parents[1]
_DEFAULT_SUBGRAPH_ROOT = (_REPO_ROOT / "yaml_instance").resolve()
_SUBGRAPH_CACHE: Dict[Path, Dict[str, Any]] = {}
def _resolve_candidate_paths(file_path: str, parent_source: str | None) -> List[Path]:
path = Path(file_path)
if path.is_absolute():
return [path]
candidates: List[Path] = []
default_candidate = (_DEFAULT_SUBGRAPH_ROOT / path).resolve()
candidates.append(default_candidate)
if parent_source:
parent = Path(parent_source)
parent_dir = parent.parent if parent.is_file() else parent
candidates.append((parent_dir / path).resolve())
# As a last resort, allow relative to repo root / current working dir
candidates.append((_REPO_ROOT / path).resolve())
return candidates
def _resolve_existing_path(candidates: List[Path]) -> Path:
checked: List[str] = []
for candidate in candidates:
checked.append(str(candidate))
if candidate.exists():
return candidate
raise ConfigError(
f"subgraph YAML not found; tried: {', '.join(checked)}",
path=checked[-1] if checked else None,
)
def _load_graph_dict(path: Path) -> Dict[str, Any]:
data = read_yaml(path)
if not isinstance(data, dict):
raise ConfigError("subgraph YAML root must be a mapping", path=str(path))
graph_block = data.get("graph")
if graph_block is None:
graph_block = data
if not isinstance(graph_block, dict):
raise ConfigError("subgraph graph section must be a mapping", path=f"{path}.graph")
vars_block = data.get("vars") if isinstance(data.get("vars"), dict) else {}
return {"graph": graph_block, "vars": vars_block}
def load_subgraph_config(file_path: str, *, parent_source: str | None = None) -> Tuple[Dict[str, Any], Dict[str, Any], str]:
"""Load a subgraph definition from disk.
Returns a tuple of (graph_dict, resolved_path).
"""
candidates = _resolve_candidate_paths(file_path, parent_source)
resolved_path = _resolve_existing_path(candidates).resolve()
if resolved_path not in _SUBGRAPH_CACHE:
_SUBGRAPH_CACHE[resolved_path] = _load_graph_dict(resolved_path)
payload = _SUBGRAPH_CACHE[resolved_path]
graph_dict = deepcopy(payload["graph"])
vars_dict = dict(payload["vars"])
return graph_dict, vars_dict, str(resolved_path)
__all__ = ["load_subgraph_config"]
+235
View File
@@ -0,0 +1,235 @@
"""Graph topology builder utility for cycle detection and topological sorting.
This module provides stateless utilities for building execution order of graphs,
supporting both global graphs and scoped subgraphs (e.g., within cycles).
"""
from typing import Dict, List, Set, Any
from entity.configs import Node
from workflow.cycle_manager import CycleDetector
class GraphTopologyBuilder:
"""
Graph topology structure builder.
Responsibilities:
1. Detect cycles (based on CycleDetector)
2. Build super-node graphs
3. Perform topological sorting
Features:
- Stateless (pure static methods)
- Can be used for both global graphs and local subgraphs
- Does not depend on specific GraphContext instances
"""
@staticmethod
def detect_cycles(nodes: Dict[str, Node]) -> List[Set[str]]:
"""
Detect cycles in the given node set.
Args:
nodes: Dictionary of nodes to analyze
Returns:
List of cycles, where each cycle is a set of node IDs
"""
detector = CycleDetector()
return detector.detect_cycles(nodes)
@staticmethod
def create_super_node_graph(
nodes: Dict[str, Node],
edges: List[Dict[str, Any]],
cycles: List[Set[str]]
) -> Dict[str, Set[str]]:
"""
Create a super-node graph where each cycle is treated as a single node.
Args:
nodes: Node dictionary
edges: Edge configuration list (only edges to consider)
cycles: List of detected cycles
Returns:
Super-node dependency graph: {super_node_id: set(predecessor_super_node_ids)}
"""
super_nodes = {}
node_to_super = {}
# Create super-nodes for cycles
for i, cycle_nodes in enumerate(cycles):
super_node_id = f"super_cycle_{i}"
super_nodes[super_node_id] = set()
for node_id in cycle_nodes:
node_to_super[node_id] = super_node_id
# Create super-nodes for non-cycle nodes (each non-cycle node is its own super-node)
for node_id in nodes.keys():
if node_id not in node_to_super:
super_node_id = f"node_{node_id}"
super_nodes[super_node_id] = set()
node_to_super[node_id] = super_node_id
# Build dependencies between super-nodes
for edge_config in edges:
from_node = edge_config["from"]
to_node = edge_config["to"]
# Skip edges not in the node set
if from_node not in nodes or to_node not in nodes:
continue
from_super = node_to_super[from_node]
to_super = node_to_super[to_node]
# Only add dependency if between different super-nodes
if from_super != to_super:
super_nodes[to_super].add(from_super)
return super_nodes
@staticmethod
def topological_sort_super_nodes(
super_node_graph: Dict[str, Set[str]],
cycles: List[Set[str]]
) -> List[List[Dict[str, Any]]]:
"""
Perform topological sort on super-node graph to determine execution order.
Args:
super_node_graph: Super-node dependency graph
cycles: List of cycles for mapping super-nodes to cycle info
Returns:
Execution layers, where each layer contains items that can be executed in parallel.
Format: [
[{"type": "node", "node_id": "A"}, {"type": "cycle", "cycle_id": "...", "nodes": [...]}],
[...]
]
"""
# Calculate in-degrees
in_degree = {
super_node: len(predecessors)
for super_node, predecessors in super_node_graph.items()
}
# Find super-nodes with no dependencies
ready = [node for node, degree in in_degree.items() if degree == 0]
execution_layers = []
# Create cycle lookup
cycle_lookup = {}
for i, cycle_nodes in enumerate(cycles):
cycle_id = f"cycle_{i}_{cycle_nodes}"
cycle_lookup[f"super_cycle_{i}"] = {
"cycle_id": cycle_id,
"nodes": cycle_nodes
}
while ready:
current_layer = ready[:]
ready.clear()
# Convert to execution items
layer_items = []
for super_node in current_layer:
if super_node.startswith("super_cycle_"):
# Cycle super-node
cycle_data = cycle_lookup[super_node]
layer_items.append({
"type": "cycle",
"cycle_id": cycle_data["cycle_id"],
"nodes": list(cycle_data["nodes"])
})
elif super_node.startswith("node_"):
# Regular node
node_id = super_node.replace("node_", "")
layer_items.append({
"type": "node",
"node_id": node_id
})
# Update dependencies
for dependent in super_node_graph:
if super_node in super_node_graph[dependent]:
super_node_graph[dependent].remove(super_node)
in_degree[dependent] -= 1
if in_degree[dependent] == 0:
ready.append(dependent)
if layer_items:
execution_layers.append(layer_items)
return execution_layers
@staticmethod
def build_execution_order(
nodes: Dict[str, Node],
edges: List[Dict[str, Any]]
) -> List[List[Dict[str, Any]]]:
"""
One-stop method to build execution order.
Combines cycle detection, super-node construction, and topological sorting.
Args:
nodes: Node dictionary
edges: Edge configuration list
Returns:
Execution layers
"""
cycles = GraphTopologyBuilder.detect_cycles(nodes)
if not cycles:
# No cycles, return DAG layers directly
return GraphTopologyBuilder.build_dag_layers(nodes)
super_graph = GraphTopologyBuilder.create_super_node_graph(
nodes, edges, cycles
)
return GraphTopologyBuilder.topological_sort_super_nodes(
super_graph, cycles
)
@staticmethod
def build_dag_layers(nodes: Dict[str, Node]) -> List[List[Dict[str, Any]]]:
"""
Build topological layers for DAG (Directed Acyclic Graph).
Args:
nodes: Node dictionary
Returns:
Layers in execution item format
"""
in_degree = {
node_id: len(node.predecessors)
for node_id, node in nodes.items()
}
frontier = [
node_id for node_id, deg in in_degree.items() if deg == 0
]
layers = []
while frontier:
# Convert to execution item format
layer_items = [
{"type": "node", "node_id": node_id}
for node_id in frontier
]
layers.append(layer_items)
next_frontier = []
for node_id in frontier:
for successor in nodes[node_id].successors:
in_degree[successor.id] -= 1
if in_degree[successor.id] == 0:
next_frontier.append(successor.id)
frontier = next_frontier
return layers