kailash 0.1.4__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. kailash/__init__.py +1 -1
  2. kailash/access_control.py +740 -0
  3. kailash/api/__main__.py +6 -0
  4. kailash/api/auth.py +668 -0
  5. kailash/api/custom_nodes.py +285 -0
  6. kailash/api/custom_nodes_secure.py +377 -0
  7. kailash/api/database.py +620 -0
  8. kailash/api/studio.py +915 -0
  9. kailash/api/studio_secure.py +893 -0
  10. kailash/mcp/__init__.py +53 -0
  11. kailash/mcp/__main__.py +13 -0
  12. kailash/mcp/ai_registry_server.py +712 -0
  13. kailash/mcp/client.py +447 -0
  14. kailash/mcp/client_new.py +334 -0
  15. kailash/mcp/server.py +293 -0
  16. kailash/mcp/server_new.py +336 -0
  17. kailash/mcp/servers/__init__.py +12 -0
  18. kailash/mcp/servers/ai_registry.py +289 -0
  19. kailash/nodes/__init__.py +4 -2
  20. kailash/nodes/ai/__init__.py +38 -0
  21. kailash/nodes/ai/a2a.py +1790 -0
  22. kailash/nodes/ai/agents.py +116 -2
  23. kailash/nodes/ai/ai_providers.py +206 -8
  24. kailash/nodes/ai/intelligent_agent_orchestrator.py +2108 -0
  25. kailash/nodes/ai/iterative_llm_agent.py +1280 -0
  26. kailash/nodes/ai/llm_agent.py +324 -1
  27. kailash/nodes/ai/self_organizing.py +1623 -0
  28. kailash/nodes/api/http.py +106 -25
  29. kailash/nodes/api/rest.py +116 -21
  30. kailash/nodes/base.py +15 -2
  31. kailash/nodes/base_async.py +45 -0
  32. kailash/nodes/base_cycle_aware.py +374 -0
  33. kailash/nodes/base_with_acl.py +338 -0
  34. kailash/nodes/code/python.py +135 -27
  35. kailash/nodes/data/readers.py +116 -53
  36. kailash/nodes/data/writers.py +16 -6
  37. kailash/nodes/logic/__init__.py +8 -0
  38. kailash/nodes/logic/async_operations.py +48 -9
  39. kailash/nodes/logic/convergence.py +642 -0
  40. kailash/nodes/logic/loop.py +153 -0
  41. kailash/nodes/logic/operations.py +212 -27
  42. kailash/nodes/logic/workflow.py +26 -18
  43. kailash/nodes/mixins/__init__.py +11 -0
  44. kailash/nodes/mixins/mcp.py +228 -0
  45. kailash/nodes/mixins.py +387 -0
  46. kailash/nodes/transform/__init__.py +8 -1
  47. kailash/nodes/transform/processors.py +119 -4
  48. kailash/runtime/__init__.py +2 -1
  49. kailash/runtime/access_controlled.py +458 -0
  50. kailash/runtime/local.py +106 -33
  51. kailash/runtime/parallel_cyclic.py +529 -0
  52. kailash/sdk_exceptions.py +90 -5
  53. kailash/security.py +845 -0
  54. kailash/tracking/manager.py +38 -15
  55. kailash/tracking/models.py +1 -1
  56. kailash/tracking/storage/filesystem.py +30 -2
  57. kailash/utils/__init__.py +8 -0
  58. kailash/workflow/__init__.py +18 -0
  59. kailash/workflow/convergence.py +270 -0
  60. kailash/workflow/cycle_analyzer.py +768 -0
  61. kailash/workflow/cycle_builder.py +573 -0
  62. kailash/workflow/cycle_config.py +709 -0
  63. kailash/workflow/cycle_debugger.py +760 -0
  64. kailash/workflow/cycle_exceptions.py +601 -0
  65. kailash/workflow/cycle_profiler.py +671 -0
  66. kailash/workflow/cycle_state.py +338 -0
  67. kailash/workflow/cyclic_runner.py +985 -0
  68. kailash/workflow/graph.py +500 -39
  69. kailash/workflow/migration.py +768 -0
  70. kailash/workflow/safety.py +365 -0
  71. kailash/workflow/templates.py +744 -0
  72. kailash/workflow/validation.py +693 -0
  73. {kailash-0.1.4.dist-info → kailash-0.2.0.dist-info}/METADATA +446 -13
  74. kailash-0.2.0.dist-info/RECORD +125 -0
  75. kailash/nodes/mcp/__init__.py +0 -11
  76. kailash/nodes/mcp/client.py +0 -554
  77. kailash/nodes/mcp/resource.py +0 -682
  78. kailash/nodes/mcp/server.py +0 -577
  79. kailash-0.1.4.dist-info/RECORD +0 -85
  80. {kailash-0.1.4.dist-info → kailash-0.2.0.dist-info}/WHEEL +0 -0
  81. {kailash-0.1.4.dist-info → kailash-0.2.0.dist-info}/entry_points.txt +0 -0
  82. {kailash-0.1.4.dist-info → kailash-0.2.0.dist-info}/licenses/LICENSE +0 -0
  83. {kailash-0.1.4.dist-info → kailash-0.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,985 @@
1
+ """Comprehensive Execution Engine for Cyclic Workflows.
2
+
3
+ This module provides the core execution engine for cyclic workflows with
4
+ advanced parameter propagation, comprehensive task tracking, and performance
5
+ monitoring. It handles both DAG and cyclic portions of workflows with
6
+ sophisticated safety mechanisms and detailed execution analytics.
7
+
8
+ Design Philosophy:
9
+ Provides a robust, production-ready execution engine that seamlessly
10
+ handles both traditional DAG workflows and complex cyclic patterns.
11
+ Emphasizes safety, observability, and performance with comprehensive
12
+ tracking at multiple granularity levels.
13
+
14
+ Key Features:
15
+ - Hybrid DAG/Cycle execution with automatic detection
16
+ - Sophisticated parameter propagation between iterations
17
+ - Multi-level task tracking (workflow → cycle → iteration → node)
18
+ - Performance metrics collection with detailed analytics
19
+ - Safety mechanisms with timeout, memory, and iteration limits
20
+ - Real-time monitoring and health checks
21
+
22
+ Execution Architecture:
23
+ - WorkflowState: Manages execution state and parameter flow
24
+ - CyclicWorkflowExecutor: Main execution orchestrator
25
+ - ExecutionPlan: Optimized execution strategy
26
+ - CycleGroup: Manages individual cycle execution
27
+ - Safety integration with CycleSafetyManager
28
+
29
+ Task Tracking Hierarchy:
30
+ 1. Workflow Run: Overall execution tracking
31
+ 2. Cycle Groups: Individual cycle execution tracking
32
+ 3. Cycle Iterations: Per-iteration execution tracking
33
+ 4. Node Executions: Individual node execution tracking
34
+ 5. Performance Metrics: Detailed timing and resource usage
35
+
36
+ Parameter Propagation:
37
+ - Initial parameters for first iteration
38
+ - Cross-iteration parameter flow with mapping
39
+ - State preservation between iterations
40
+ - Convergence value tracking
41
+ - Error handling and recovery
42
+
43
+ Upstream Dependencies:
44
+ - Workflow graph structure and validation
45
+ - Node implementations and execution contracts
46
+ - Safety managers for resource limits
47
+ - Task tracking and metrics collection systems
48
+
49
+ Downstream Consumers:
50
+ - Runtime engines for workflow execution
51
+ - Monitoring systems for execution tracking
52
+ - Performance analysis and optimization tools
53
+ - Debug and development tools
54
+
55
+ Safety and Monitoring:
56
+ - Configurable safety limits (iterations, timeout, memory)
57
+ - Real-time convergence monitoring
58
+ - Resource usage tracking and alerting
59
+ - Graceful degradation and error recovery
60
+ - Comprehensive logging and debugging support
61
+
62
+ Examples:
63
+ Basic cyclic execution:
64
+
65
+ >>> executor = CyclicWorkflowExecutor()
66
+ >>> # Execute workflow with cycles
67
+ >>> results, run_id = executor.execute(
68
+ ... workflow,
69
+ ... parameters={"initial_value": 10},
70
+ ... task_manager=task_manager
71
+ ... )
72
+
73
+ With safety configuration:
74
+
75
+ >>> from kailash.workflow.safety import CycleSafetyManager
76
+ >>> safety_manager = CycleSafetyManager(
77
+ ... default_max_iterations=100,
78
+ ... default_timeout=300,
79
+ ... default_memory_limit=1024
80
+ ... )
81
+ >>> executor = CyclicWorkflowExecutor(safety_manager)
82
+ >>> results, run_id = executor.execute(workflow, parameters)
83
+
84
+ With comprehensive tracking:
85
+
86
+ >>> from kailash.tracking import TaskManager
87
+ >>> task_manager = TaskManager()
88
+ >>> results, run_id = executor.execute(
89
+ ... workflow, parameters, task_manager, run_id="custom_run_001"
90
+ ... )
91
+ >>> # Access detailed tracking information
92
+ >>> tasks = task_manager.get_tasks_for_run(run_id)
93
+ >>> metrics = task_manager.get_metrics_for_run(run_id)
94
+
95
+ See Also:
96
+ - :mod:`kailash.workflow.safety` for safety mechanisms and limits
97
+ - :mod:`kailash.tracking` for comprehensive execution tracking
98
+ - :mod:`kailash.workflow.convergence` for convergence conditions
99
+ """
100
+
101
+ import logging
102
+ from datetime import datetime, timezone
103
+ from typing import Any, Dict, List, Optional, Set, Tuple
104
+
105
+ import networkx as nx
106
+
107
+ from kailash.sdk_exceptions import WorkflowExecutionError, WorkflowValidationError
108
+ from kailash.tracking import TaskManager, TaskStatus
109
+ from kailash.tracking.metrics_collector import MetricsCollector
110
+ from kailash.tracking.models import TaskMetrics
111
+ from kailash.workflow.convergence import create_convergence_condition
112
+ from kailash.workflow.cycle_state import CycleState, CycleStateManager
113
+ from kailash.workflow.graph import Workflow
114
+ from kailash.workflow.runner import WorkflowRunner
115
+ from kailash.workflow.safety import CycleSafetyManager, monitored_cycle
116
+
117
+ logger = logging.getLogger(__name__)
118
+
119
+
120
+ class WorkflowState:
121
+ """Simple workflow execution state container."""
122
+
123
+ def __init__(self, run_id: str):
124
+ """Initialize workflow state.
125
+
126
+ Args:
127
+ run_id: Unique execution run ID
128
+ """
129
+ self.run_id = run_id
130
+ self.node_outputs: Dict[str, Any] = {}
131
+ self.execution_order: List[str] = []
132
+ self.metadata: Dict[str, Any] = {}
133
+
134
+
135
+ class CyclicWorkflowExecutor:
136
+ """Execution engine supporting cyclic workflows with fixed parameter propagation."""
137
+
138
+ def __init__(self, safety_manager: Optional[CycleSafetyManager] = None):
139
+ """Initialize cyclic workflow executor.
140
+
141
+ Args:
142
+ safety_manager: Optional safety manager for resource limits
143
+ """
144
+ self.safety_manager = safety_manager or CycleSafetyManager()
145
+ self.cycle_state_manager = CycleStateManager()
146
+ self.dag_runner = WorkflowRunner() # For executing DAG portions
147
+
148
+ def execute(
149
+ self,
150
+ workflow: Workflow,
151
+ parameters: Optional[Dict[str, Any]] = None,
152
+ task_manager: Optional[TaskManager] = None,
153
+ run_id: Optional[str] = None,
154
+ ) -> Tuple[Dict[str, Any], str]:
155
+ """Execute workflow with cycle support.
156
+
157
+ Args:
158
+ workflow: Workflow to execute
159
+ parameters: Initial parameters/overrides
160
+ task_manager: Optional task manager for tracking execution
161
+ run_id: Optional run ID to use (if not provided, one will be generated)
162
+
163
+ Returns:
164
+ Tuple of (results dict, run_id)
165
+
166
+ Raises:
167
+ WorkflowExecutionError: If execution fails
168
+ WorkflowValidationError: If workflow is invalid
169
+ """
170
+ # Validate workflow (including cycles)
171
+ workflow.validate()
172
+
173
+ # Generate run ID if not provided
174
+ if not run_id:
175
+ import uuid
176
+
177
+ run_id = str(uuid.uuid4())
178
+
179
+ logger.info(
180
+ f"Starting cyclic workflow execution: {workflow.name} (run_id: {run_id})"
181
+ )
182
+
183
+ # Check if workflow has cycles
184
+ if not workflow.has_cycles():
185
+ # No cycles, use standard DAG execution
186
+ logger.info("No cycles detected, using standard DAG execution")
187
+ return self.dag_runner.run(workflow, parameters), run_id
188
+
189
+ # Execute with cycle support
190
+ try:
191
+ results = self._execute_with_cycles(
192
+ workflow, parameters, run_id, task_manager
193
+ )
194
+ logger.info(f"Cyclic workflow execution completed: {workflow.name}")
195
+ return results, run_id
196
+
197
+ except Exception as e:
198
+ logger.error(f"Cyclic workflow execution failed: {e}")
199
+ raise WorkflowExecutionError(f"Execution failed: {e}") from e
200
+
201
+ finally:
202
+ # Clean up cycle states
203
+ self.cycle_state_manager.clear()
204
+
205
+ def _filter_none_values(self, obj: Any) -> Any:
206
+ """Recursively filter None values from nested dictionaries.
207
+
208
+ Args:
209
+ obj: Object to filter (dict, list, or other)
210
+
211
+ Returns:
212
+ Filtered object with None values removed
213
+ """
214
+ if isinstance(obj, dict):
215
+ return {
216
+ k: self._filter_none_values(v) for k, v in obj.items() if v is not None
217
+ }
218
+ elif isinstance(obj, list):
219
+ return [self._filter_none_values(item) for item in obj if item is not None]
220
+ else:
221
+ return obj
222
+
223
+ def _execute_with_cycles(
224
+ self,
225
+ workflow: Workflow,
226
+ parameters: Optional[Dict[str, Any]],
227
+ run_id: str,
228
+ task_manager: Optional[TaskManager] = None,
229
+ ) -> Dict[str, Any]:
230
+ """Execute workflow with cycle handling.
231
+
232
+ Args:
233
+ workflow: Workflow to execute
234
+ parameters: Initial parameters
235
+ run_id: Execution run ID
236
+ task_manager: Optional task manager for tracking
237
+
238
+ Returns:
239
+ Final execution results
240
+ """
241
+ # Separate DAG and cycle edges
242
+ dag_edges, cycle_edges = workflow.separate_dag_and_cycle_edges()
243
+ cycle_groups = workflow.get_cycle_groups()
244
+
245
+ # Create execution plan
246
+ execution_plan = self._create_execution_plan(workflow, dag_edges, cycle_groups)
247
+
248
+ # Initialize workflow state
249
+ state = WorkflowState(run_id=run_id)
250
+
251
+ # Store initial parameters separately (don't treat them as outputs!)
252
+ state.initial_parameters = parameters or {}
253
+
254
+ # Execute the plan
255
+ results = self._execute_plan(workflow, execution_plan, state, task_manager)
256
+
257
+ # Log cycle summaries
258
+ summaries = self.cycle_state_manager.get_all_summaries()
259
+ for cycle_id, summary in summaries.items():
260
+ logger.info(f"Cycle {cycle_id} summary: {summary}")
261
+
262
+ return results
263
+
264
+ def _create_execution_plan(
265
+ self,
266
+ workflow: Workflow,
267
+ dag_edges: List[Tuple],
268
+ cycle_groups: Dict[str, List[Tuple]],
269
+ ) -> "ExecutionPlan":
270
+ """Create execution plan handling cycles.
271
+
272
+ Args:
273
+ workflow: Workflow instance
274
+ dag_edges: List of DAG edges
275
+ cycle_groups: Grouped cycle edges
276
+
277
+ Returns:
278
+ ExecutionPlan instance
279
+ """
280
+ plan = ExecutionPlan()
281
+
282
+ # Create DAG-only graph for topological analysis
283
+ dag_graph = nx.DiGraph()
284
+ dag_graph.add_nodes_from(workflow.graph.nodes(data=True))
285
+ for source, target, data in dag_edges:
286
+ dag_graph.add_edge(source, target, **data)
287
+
288
+ # Get topological order for DAG portion
289
+ try:
290
+ topo_order = list(nx.topological_sort(dag_graph))
291
+ except nx.NetworkXUnfeasible:
292
+ raise WorkflowValidationError("DAG portion contains unmarked cycles")
293
+
294
+ # Identify cycle entry and exit points
295
+ for cycle_id, cycle_edges in cycle_groups.items():
296
+ cycle_nodes = set()
297
+ entry_nodes = set()
298
+ exit_nodes = set()
299
+
300
+ for source, target, data in cycle_edges:
301
+ cycle_nodes.add(source)
302
+ cycle_nodes.add(target)
303
+
304
+ # Entry nodes have incoming edges from non-cycle nodes
305
+ for pred in workflow.graph.predecessors(target):
306
+ if pred not in cycle_nodes:
307
+ entry_nodes.add(target)
308
+
309
+ # Exit nodes have outgoing edges to non-cycle nodes
310
+ for succ in workflow.graph.successors(source):
311
+ if succ not in cycle_nodes:
312
+ exit_nodes.add(source)
313
+
314
+ plan.add_cycle_group(
315
+ cycle_id=cycle_id,
316
+ nodes=cycle_nodes,
317
+ entry_nodes=entry_nodes,
318
+ exit_nodes=exit_nodes,
319
+ edges=cycle_edges,
320
+ )
321
+
322
+ # Build execution stages
323
+ plan.build_stages(topo_order, dag_graph)
324
+
325
+ return plan
326
+
327
+ def _execute_plan(
328
+ self,
329
+ workflow: Workflow,
330
+ plan: "ExecutionPlan",
331
+ state: WorkflowState,
332
+ task_manager: Optional[TaskManager] = None,
333
+ ) -> Dict[str, Any]:
334
+ """Execute the workflow plan.
335
+
336
+ Args:
337
+ workflow: Workflow instance
338
+ plan: Execution plan
339
+ state: Workflow state
340
+ task_manager: Optional task manager for tracking
341
+
342
+ Returns:
343
+ Execution results
344
+ """
345
+ results = {}
346
+
347
+ logger.info(f"Executing plan with {len(plan.stages)} stages")
348
+
349
+ for i, stage in enumerate(plan.stages):
350
+ logger.info(
351
+ f"Executing stage {i+1}: is_cycle={stage.is_cycle}, nodes={getattr(stage, 'nodes', 'N/A')}"
352
+ )
353
+ if stage.is_cycle:
354
+ logger.info(
355
+ f"Stage {i+1} is a cycle group: {stage.cycle_group.cycle_id}"
356
+ )
357
+ # Execute cycle group
358
+ cycle_results = self._execute_cycle_group(
359
+ workflow, stage.cycle_group, state, task_manager
360
+ )
361
+ results.update(cycle_results)
362
+ else:
363
+ # Execute DAG nodes
364
+ for node_id in stage.nodes:
365
+ if node_id not in state.node_outputs:
366
+ logger.info(f"Executing DAG node: {node_id}")
367
+ node_result = self._execute_node(
368
+ workflow, node_id, state, task_manager=task_manager
369
+ )
370
+ results[node_id] = node_result
371
+ state.node_outputs[node_id] = node_result
372
+
373
+ return results
374
+
375
+ def _execute_cycle_group(
376
+ self,
377
+ workflow: Workflow,
378
+ cycle_group: "CycleGroup",
379
+ state: WorkflowState,
380
+ task_manager: Optional[TaskManager] = None,
381
+ ) -> Dict[str, Any]:
382
+ """Execute a cycle group with proper parameter propagation.
383
+
384
+ Args:
385
+ workflow: Workflow instance
386
+ cycle_group: Cycle group to execute
387
+ state: Workflow state
388
+ task_manager: Optional task manager for tracking
389
+
390
+ Returns:
391
+ Cycle execution results
392
+ """
393
+ cycle_id = cycle_group.cycle_id
394
+ logger.info(f"*** EXECUTING CYCLE GROUP: {cycle_id} ***")
395
+ logger.info(f"Cycle nodes: {cycle_group.nodes}")
396
+ logger.info(f"Cycle edges: {cycle_group.edges}")
397
+
398
+ # Get cycle configuration from first edge
399
+ cycle_config = {}
400
+ convergence_check = None
401
+ if cycle_group.edges:
402
+ _, _, edge_data = cycle_group.edges[0]
403
+ # Extract convergence check separately
404
+ convergence_check = edge_data.get("convergence_check")
405
+ # Safety config only includes safety-related parameters
406
+ cycle_config = {
407
+ "max_iterations": edge_data.get("max_iterations"),
408
+ "timeout": edge_data.get("timeout"),
409
+ "memory_limit": edge_data.get("memory_limit"),
410
+ }
411
+
412
+ # Create convergence condition
413
+ convergence_condition = None
414
+ if convergence_check:
415
+ convergence_condition = create_convergence_condition(convergence_check)
416
+
417
+ # Get or create cycle state
418
+ cycle_state = self.cycle_state_manager.get_or_create_state(cycle_id)
419
+
420
+ # Start monitoring
421
+ with monitored_cycle(self.safety_manager, cycle_id, **cycle_config) as monitor:
422
+ results = {}
423
+
424
+ # Store previous iteration results for parameter propagation
425
+ previous_iteration_results = {}
426
+
427
+ # Create cycle group task if task manager available
428
+ cycle_task_id = None
429
+ if task_manager and state.run_id:
430
+ try:
431
+ cycle_task = task_manager.create_task(
432
+ run_id=state.run_id,
433
+ node_id=f"cycle_group_{cycle_id}",
434
+ node_type="CycleGroup",
435
+ started_at=datetime.now(timezone.utc),
436
+ metadata={
437
+ "cycle_id": cycle_id,
438
+ "max_iterations": cycle_config.get("max_iterations"),
439
+ "nodes_in_cycle": list(cycle_group.nodes),
440
+ },
441
+ )
442
+ if cycle_task:
443
+ cycle_task_id = cycle_task.task_id
444
+ task_manager.update_task_status(
445
+ cycle_task_id, TaskStatus.RUNNING
446
+ )
447
+ except Exception as e:
448
+ logger.warning(f"Failed to create cycle group task: {e}")
449
+
450
+ loop_count = 0
451
+ while True:
452
+ loop_count += 1
453
+ logger.info(f"Cycle {cycle_id} - Starting loop iteration {loop_count}")
454
+
455
+ # Record iteration
456
+ monitor.record_iteration()
457
+
458
+ # Create iteration task if task manager available
459
+ iteration_task_id = None
460
+ if task_manager and state.run_id:
461
+ try:
462
+ iteration_task = task_manager.create_task(
463
+ run_id=state.run_id,
464
+ node_id=f"cycle_{cycle_id}_iteration_{loop_count}",
465
+ node_type="CycleIteration",
466
+ started_at=datetime.now(timezone.utc),
467
+ metadata={
468
+ "cycle_id": cycle_id,
469
+ "iteration": loop_count,
470
+ "parent_task": cycle_task_id,
471
+ },
472
+ )
473
+ if iteration_task:
474
+ iteration_task_id = iteration_task.task_id
475
+ task_manager.update_task_status(
476
+ iteration_task_id, TaskStatus.RUNNING
477
+ )
478
+ except Exception as e:
479
+ logger.warning(f"Failed to create iteration task: {e}")
480
+
481
+ # Execute nodes in cycle
482
+ iteration_results = {}
483
+ for node_id in cycle_group.get_execution_order(workflow.graph):
484
+ node_result = self._execute_node(
485
+ workflow,
486
+ node_id,
487
+ state,
488
+ cycle_state,
489
+ cycle_edges=cycle_group.edges,
490
+ previous_iteration_results=previous_iteration_results,
491
+ task_manager=task_manager,
492
+ iteration=loop_count,
493
+ )
494
+ iteration_results[node_id] = node_result
495
+ state.node_outputs[node_id] = node_result
496
+
497
+ # Update results for this iteration
498
+ results.update(iteration_results)
499
+
500
+ # Store this iteration's results for next iteration
501
+ previous_iteration_results = iteration_results.copy()
502
+
503
+ # Log iteration info BEFORE state update
504
+ logger.info(
505
+ f"Cycle {cycle_id} iteration {cycle_state.iteration} (before update) results: {iteration_results}"
506
+ )
507
+
508
+ # Update cycle state
509
+ cycle_state.update(iteration_results)
510
+
511
+ # Check convergence
512
+ should_terminate = False
513
+
514
+ # Log after update
515
+ logger.info(
516
+ f"Cycle {cycle_id} iteration now at {cycle_state.iteration} (after update)"
517
+ )
518
+
519
+ # Check max iterations (built into monitor.record_iteration)
520
+ if cycle_state.iteration >= cycle_config.get(
521
+ "max_iterations", float("inf")
522
+ ):
523
+ logger.info(
524
+ f"Cycle {cycle_id} reached max iterations: {cycle_state.iteration}"
525
+ )
526
+ should_terminate = True
527
+
528
+ # Check convergence condition
529
+ if convergence_condition:
530
+ converged = convergence_condition.evaluate(
531
+ iteration_results, cycle_state
532
+ )
533
+ logger.info(
534
+ f"Cycle {cycle_id} convergence check: {convergence_condition.describe()} = {converged}"
535
+ )
536
+ if converged:
537
+ logger.info(
538
+ f"Cycle {cycle_id} converged: {convergence_condition.describe()}"
539
+ )
540
+ should_terminate = True
541
+
542
+ # Check safety violations
543
+ if monitor.check_violations():
544
+ logger.warning(
545
+ f"Cycle {cycle_id} safety violation: {monitor.violations}"
546
+ )
547
+ should_terminate = True
548
+
549
+ # Complete iteration task
550
+ if iteration_task_id and task_manager:
551
+ try:
552
+ task_manager.update_task_status(
553
+ iteration_task_id,
554
+ TaskStatus.COMPLETED,
555
+ ended_at=datetime.now(timezone.utc),
556
+ result=iteration_results,
557
+ metadata={
558
+ "converged": (
559
+ converged if "converged" in locals() else False
560
+ ),
561
+ "terminated": should_terminate,
562
+ },
563
+ )
564
+ except Exception as e:
565
+ logger.warning(f"Failed to update iteration task: {e}")
566
+
567
+ if should_terminate:
568
+ logger.info(
569
+ f"Cycle {cycle_id} terminating after {loop_count} iterations"
570
+ )
571
+ break
572
+
573
+ logger.info(f"Cycle {cycle_id} continuing to next iteration")
574
+
575
+ # Complete cycle group task
576
+ if cycle_task_id and task_manager:
577
+ try:
578
+ summary = cycle_state.get_summary()
579
+ task_manager.update_task_status(
580
+ cycle_task_id,
581
+ TaskStatus.COMPLETED,
582
+ ended_at=datetime.now(timezone.utc),
583
+ result=results,
584
+ metadata={
585
+ "total_iterations": loop_count,
586
+ "converged": (
587
+ converged if "converged" in locals() else False
588
+ ),
589
+ "summary": summary,
590
+ },
591
+ )
592
+ except Exception as e:
593
+ logger.warning(f"Failed to update cycle group task: {e}")
594
+
595
+ # Log cycle completion
596
+ summary = cycle_state.get_summary()
597
+ logger.info(f"Cycle {cycle_id} completed: {summary}")
598
+
599
+ return results
600
+
601
+ def _execute_node(
602
+ self,
603
+ workflow: Workflow,
604
+ node_id: str,
605
+ state: WorkflowState,
606
+ cycle_state: Optional[CycleState] = None,
607
+ cycle_edges: Optional[List[Tuple]] = None,
608
+ previous_iteration_results: Optional[Dict[str, Any]] = None,
609
+ task_manager: Optional[TaskManager] = None,
610
+ iteration: Optional[int] = None,
611
+ ) -> Any:
612
+ """Execute a single node with proper parameter handling for cycles.
613
+
614
+ Args:
615
+ workflow: Workflow instance
616
+ node_id: Node to execute
617
+ state: Workflow state
618
+ cycle_state: Optional cycle state
619
+ cycle_edges: List of edges in the current cycle
620
+ previous_iteration_results: Results from previous cycle iteration
621
+
622
+ Returns:
623
+ Node execution result
624
+ """
625
+ node = workflow.get_node(node_id)
626
+ if not node:
627
+ raise WorkflowExecutionError(f"Node not found: {node_id}")
628
+
629
+ # Gather inputs from connections
630
+ inputs = {}
631
+
632
+ # Check if we're in a cycle and this is not the first iteration
633
+ in_cycle = cycle_state is not None
634
+ is_cycle_iteration = in_cycle and cycle_state.iteration > 0
635
+
636
+ for pred, _, edge_data in workflow.graph.in_edges(node_id, data=True):
637
+ # Check if this edge is a cycle edge (but NOT synthetic)
638
+ is_cycle_edge = edge_data.get("cycle", False) and not edge_data.get(
639
+ "synthetic", False
640
+ )
641
+
642
+ # Determine where to get the predecessor output from
643
+ if is_cycle_edge and is_cycle_iteration and previous_iteration_results:
644
+ # For cycle edges after first iteration, use previous iteration results
645
+ pred_output = previous_iteration_results.get(pred)
646
+ logger.debug(
647
+ f"Using previous iteration result for {pred} -> {node_id}: {type(pred_output)} keys={list(pred_output.keys()) if isinstance(pred_output, dict) else 'not dict'}"
648
+ )
649
+ elif pred in state.node_outputs:
650
+ # For non-cycle edges or first iteration, use normal state
651
+ pred_output = state.node_outputs[pred]
652
+ else:
653
+ # No output available
654
+ continue
655
+
656
+ if pred_output is None:
657
+ continue
658
+
659
+ # Apply mapping
660
+ mapping = edge_data.get("mapping", {})
661
+ if is_cycle_edge and is_cycle_iteration:
662
+ logger.debug(
663
+ f"Applying cycle mapping: {mapping} from {pred} to {node_id}"
664
+ )
665
+ for src_key, dst_key in mapping.items():
666
+ # Handle nested output access
667
+ if "." in src_key:
668
+ # Navigate nested structure
669
+ value = pred_output
670
+ for part in src_key.split("."):
671
+ if isinstance(value, dict) and part in value:
672
+ value = value[part]
673
+ else:
674
+ value = None
675
+ break
676
+ if value is not None:
677
+ inputs[dst_key] = value
678
+ elif isinstance(pred_output, dict) and src_key in pred_output:
679
+ inputs[dst_key] = pred_output[src_key]
680
+ if is_cycle_edge and is_cycle_iteration:
681
+ logger.debug(
682
+ f"Mapped {src_key}={pred_output[src_key]} to {dst_key}"
683
+ )
684
+ elif src_key == "output":
685
+ # Default output mapping
686
+ inputs[dst_key] = pred_output
687
+
688
+ # Create context with cycle information
689
+ context = {
690
+ "workflow_id": workflow.workflow_id,
691
+ "run_id": state.run_id,
692
+ "node_id": node_id,
693
+ }
694
+
695
+ if cycle_state:
696
+ cycle_context = {
697
+ "cycle_id": cycle_state.cycle_id,
698
+ "iteration": cycle_state.iteration,
699
+ "elapsed_time": cycle_state.elapsed_time,
700
+ }
701
+ # Only add node_state if it's not None to avoid security validation errors
702
+ node_state = cycle_state.get_node_state(node_id)
703
+ if node_state is not None:
704
+ cycle_context["node_state"] = node_state
705
+ context["cycle"] = cycle_context
706
+
707
+ # Recursively filter None values from context to avoid security validation errors
708
+ context = self._filter_none_values(context)
709
+
710
+ # Debug inputs before merging
711
+ if cycle_state and cycle_state.iteration > 0:
712
+ logger.debug(f"Inputs gathered from connections: {inputs}")
713
+
714
+ # Merge node config with inputs
715
+ # Order: config < initial_parameters < connection inputs
716
+ merged_inputs = {**node.config}
717
+
718
+ # Add initial parameters if available and node hasn't been executed yet
719
+ if hasattr(state, "initial_parameters") and node_id in state.initial_parameters:
720
+ if node_id not in state.node_outputs or (
721
+ cycle_state and cycle_state.iteration == 0
722
+ ):
723
+ # Use initial parameters on first execution
724
+ merged_inputs.update(state.initial_parameters[node_id])
725
+
726
+ # Connection inputs override everything
727
+ merged_inputs.update(inputs)
728
+
729
+ # Filter out None values to avoid security validation errors
730
+ merged_inputs = {k: v for k, v in merged_inputs.items() if v is not None}
731
+
732
+ # Create task for node execution if task manager available
733
+ task = None
734
+ if task_manager and state.run_id:
735
+ try:
736
+ # Build task node ID based on context
737
+ task_node_id = node_id
738
+ if cycle_state and iteration:
739
+ task_node_id = (
740
+ f"{node_id}_cycle_{cycle_state.cycle_id}_iteration_{iteration}"
741
+ )
742
+
743
+ # Create metadata
744
+ task_metadata = {
745
+ "node_type": node.__class__.__name__,
746
+ }
747
+ if cycle_state:
748
+ task_metadata.update(
749
+ {
750
+ "cycle_id": cycle_state.cycle_id,
751
+ "iteration": iteration or cycle_state.iteration,
752
+ "in_cycle": True,
753
+ }
754
+ )
755
+
756
+ task = task_manager.create_task(
757
+ run_id=state.run_id,
758
+ node_id=task_node_id,
759
+ node_type=node.__class__.__name__,
760
+ started_at=datetime.now(timezone.utc),
761
+ metadata=task_metadata,
762
+ )
763
+ if task:
764
+ task_manager.update_task_status(task.task_id, TaskStatus.RUNNING)
765
+ except Exception as e:
766
+ logger.warning(f"Failed to create task for node '{node_id}': {e}")
767
+
768
+ # Execute node with metrics collection
769
+ collector = MetricsCollector()
770
+ logger.debug(
771
+ f"Executing node: {node_id} (iteration: {cycle_state.iteration if cycle_state else 'N/A'})"
772
+ )
773
+ logger.debug(f"Node inputs: {list(merged_inputs.keys())}")
774
+ if cycle_state:
775
+ logger.debug(
776
+ f"Input values - value: {merged_inputs.get('value')}, counter: {merged_inputs.get('counter')}"
777
+ )
778
+
779
+ try:
780
+ with collector.collect(node_id=node_id) as metrics_context:
781
+ result = node.run(context=context, **merged_inputs)
782
+
783
+ # Get performance metrics
784
+ performance_metrics = metrics_context.result()
785
+
786
+ # Update task status with metrics
787
+ if task and task_manager:
788
+ try:
789
+ # Convert performance metrics to TaskMetrics format
790
+ task_metrics_data = performance_metrics.to_task_metrics()
791
+ task_metrics = TaskMetrics(**task_metrics_data)
792
+
793
+ task_manager.update_task_status(
794
+ task.task_id,
795
+ TaskStatus.COMPLETED,
796
+ result=result,
797
+ ended_at=datetime.now(timezone.utc),
798
+ metadata={"execution_time": performance_metrics.duration},
799
+ )
800
+
801
+ # Update task metrics
802
+ task_manager.update_task_metrics(task.task_id, task_metrics)
803
+ except Exception as e:
804
+ logger.warning(f"Failed to update task for node '{node_id}': {e}")
805
+
806
+ except Exception as e:
807
+ # Update task status on failure
808
+ if task and task_manager:
809
+ try:
810
+ task_manager.update_task_status(
811
+ task.task_id,
812
+ TaskStatus.FAILED,
813
+ error=str(e),
814
+ ended_at=datetime.now(timezone.utc),
815
+ )
816
+ except Exception as update_error:
817
+ logger.warning(
818
+ f"Failed to update task status on error: {update_error}"
819
+ )
820
+ raise
821
+
822
+ # Store node state if in cycle
823
+ if cycle_state and isinstance(result, dict) and "_cycle_state" in result:
824
+ cycle_state.set_node_state(node_id, result["_cycle_state"])
825
+
826
+ return result
827
+
828
+
829
+ class ExecutionPlan:
830
+ """Execution plan for workflows with cycles."""
831
+
832
+ def __init__(self):
833
+ """Initialize execution plan."""
834
+ self.stages: List["ExecutionStage"] = []
835
+ self.cycle_groups: Dict[str, "CycleGroup"] = {}
836
+
837
+ def add_cycle_group(
838
+ self,
839
+ cycle_id: str,
840
+ nodes: Set[str],
841
+ entry_nodes: Set[str],
842
+ exit_nodes: Set[str],
843
+ edges: List[Tuple],
844
+ ) -> None:
845
+ """Add a cycle group to the plan.
846
+
847
+ Args:
848
+ cycle_id: Cycle identifier
849
+ nodes: Nodes in the cycle
850
+ entry_nodes: Entry points to the cycle
851
+ exit_nodes: Exit points from the cycle
852
+ edges: Cycle edges
853
+ """
854
+ self.cycle_groups[cycle_id] = CycleGroup(
855
+ cycle_id=cycle_id,
856
+ nodes=nodes,
857
+ entry_nodes=entry_nodes,
858
+ exit_nodes=exit_nodes,
859
+ edges=edges,
860
+ )
861
+
862
+ def build_stages(self, topo_order: List[str], dag_graph: nx.DiGraph) -> None:
863
+ """Build execution stages.
864
+
865
+ Args:
866
+ topo_order: Topological order of DAG nodes
867
+ dag_graph: DAG portion of the graph
868
+ """
869
+ # Track which nodes have been scheduled
870
+ scheduled = set()
871
+
872
+ logger.debug(
873
+ f"Building stages - cycle_groups: {list(self.cycle_groups.keys())}"
874
+ )
875
+ logger.debug(f"Building stages - topo_order: {topo_order}")
876
+
877
+ for node_id in topo_order:
878
+ if node_id in scheduled:
879
+ continue
880
+
881
+ # Check if node is part of a cycle
882
+ in_cycle_id = None
883
+ found_cycle_group = None
884
+ for cycle_id, cycle_group in self.cycle_groups.items():
885
+ logger.debug(
886
+ f"Checking node {node_id} against cycle {cycle_id} with nodes {cycle_group.nodes}"
887
+ )
888
+ if node_id in cycle_group.nodes:
889
+ in_cycle_id = cycle_id
890
+ found_cycle_group = cycle_group
891
+ logger.debug(f"Node {node_id} found in cycle {cycle_id}")
892
+ break
893
+
894
+ logger.debug(
895
+ f"in_cycle_id value: {in_cycle_id}, found_cycle_group: {found_cycle_group is not None}"
896
+ )
897
+ if found_cycle_group is not None:
898
+ logger.debug(f"Scheduling cycle group {in_cycle_id} for node {node_id}")
899
+ # Schedule entire cycle group
900
+ self.stages.append(
901
+ ExecutionStage(is_cycle=True, cycle_group=found_cycle_group)
902
+ )
903
+ scheduled.update(found_cycle_group.nodes)
904
+ else:
905
+ logger.debug(f"Scheduling DAG node {node_id}")
906
+ # Schedule DAG node
907
+ self.stages.append(ExecutionStage(is_cycle=False, nodes=[node_id]))
908
+ scheduled.add(node_id)
909
+
910
+
911
+ class ExecutionStage:
912
+ """Single stage in execution plan."""
913
+
914
+ def __init__(
915
+ self,
916
+ is_cycle: bool,
917
+ nodes: Optional[List[str]] = None,
918
+ cycle_group: Optional["CycleGroup"] = None,
919
+ ):
920
+ """Initialize execution stage.
921
+
922
+ Args:
923
+ is_cycle: Whether this is a cycle stage
924
+ nodes: List of nodes (for DAG stage)
925
+ cycle_group: Cycle group (for cycle stage)
926
+ """
927
+ self.is_cycle = is_cycle
928
+ self.nodes = nodes or []
929
+ self.cycle_group = cycle_group
930
+
931
+
932
+ class CycleGroup:
933
+ """Group of nodes forming a cycle."""
934
+
935
+ def __init__(
936
+ self,
937
+ cycle_id: str,
938
+ nodes: Set[str],
939
+ entry_nodes: Set[str],
940
+ exit_nodes: Set[str],
941
+ edges: List[Tuple],
942
+ ):
943
+ """Initialize cycle group.
944
+
945
+ Args:
946
+ cycle_id: Cycle identifier
947
+ nodes: Nodes in the cycle
948
+ entry_nodes: Entry points to the cycle
949
+ exit_nodes: Exit points from the cycle
950
+ edges: Cycle edges
951
+ """
952
+ self.cycle_id = cycle_id
953
+ self.nodes = nodes
954
+ self.entry_nodes = entry_nodes
955
+ self.exit_nodes = exit_nodes
956
+ self.edges = edges
957
+
958
+ def get_execution_order(self, full_graph: nx.DiGraph) -> List[str]:
959
+ """Get execution order for nodes in cycle.
960
+
961
+ Args:
962
+ full_graph: Full workflow graph
963
+
964
+ Returns:
965
+ Ordered list of node IDs
966
+ """
967
+ # Create subgraph with only cycle nodes
968
+ cycle_subgraph = full_graph.subgraph(self.nodes)
969
+
970
+ # Try topological sort on the subgraph (might work if cycle edges removed)
971
+ try:
972
+ # Remove cycle edges temporarily
973
+ temp_graph = cycle_subgraph.copy()
974
+ for source, target, _ in self.edges:
975
+ if temp_graph.has_edge(source, target):
976
+ temp_graph.remove_edge(source, target)
977
+
978
+ return list(nx.topological_sort(temp_graph))
979
+ except (nx.NetworkXError, nx.NetworkXUnfeasible):
980
+ # Fall back to entry nodes first, then others
981
+ order = list(self.entry_nodes)
982
+ for node in self.nodes:
983
+ if node not in order:
984
+ order.append(node)
985
+ return order