agent-framework-declarative 1.0.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.
@@ -0,0 +1,1057 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ """Builder that transforms declarative YAML into a workflow graph.
4
+
5
+ This module provides the DeclarativeWorkflowBuilder which is analogous to
6
+ .NET's WorkflowActionVisitor + WorkflowElementWalker. It walks the YAML
7
+ action definitions and creates a proper workflow graph with:
8
+ - Executor nodes for each action
9
+ - Edges for sequential flow
10
+ - Condition evaluator executors for If/ConditionGroup that ensure first-match semantics
11
+ - Loop edges for foreach
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from typing import Any, cast
18
+
19
+ from agent_framework import (
20
+ Workflow,
21
+ WorkflowBuilder,
22
+ )
23
+
24
+ from ._declarative_base import (
25
+ ConditionResult,
26
+ DeclarativeActionExecutor,
27
+ DeclarativeEnvConfig,
28
+ LoopIterationResult,
29
+ )
30
+ from ._errors import DeclarativeWorkflowError
31
+ from ._executors_agents import AGENT_ACTION_EXECUTORS, InvokeAzureAgentExecutor
32
+ from ._executors_basic import BASIC_ACTION_EXECUTORS
33
+ from ._executors_control_flow import (
34
+ CONTROL_FLOW_EXECUTORS,
35
+ ELSE_BRANCH_INDEX,
36
+ ConditionGroupEvaluatorExecutor,
37
+ ForeachInitExecutor,
38
+ ForeachNextExecutor,
39
+ IfConditionEvaluatorExecutor,
40
+ JoinExecutor,
41
+ )
42
+ from ._executors_external_input import EXTERNAL_INPUT_EXECUTORS
43
+ from ._executors_http import HTTP_ACTION_EXECUTORS, HttpRequestActionExecutor
44
+ from ._executors_mcp import MCP_ACTION_EXECUTORS, InvokeMcpToolActionExecutor
45
+ from ._executors_tools import TOOL_ACTION_EXECUTORS, InvokeFunctionToolExecutor
46
+ from ._http_handler import HttpRequestHandler
47
+ from ._mcp_handler import MCPToolHandler
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+
52
+ # Combined mapping of all action kinds to executor classes
53
+ ALL_ACTION_EXECUTORS = {
54
+ **BASIC_ACTION_EXECUTORS,
55
+ **CONTROL_FLOW_EXECUTORS,
56
+ **AGENT_ACTION_EXECUTORS,
57
+ **EXTERNAL_INPUT_EXECUTORS,
58
+ **TOOL_ACTION_EXECUTORS,
59
+ **HTTP_ACTION_EXECUTORS,
60
+ **MCP_ACTION_EXECUTORS,
61
+ }
62
+
63
+ # Action kinds that terminate control flow (no fall-through to successor)
64
+ # These actions transfer control elsewhere and should not have sequential edges to the next action
65
+ TERMINATOR_ACTIONS = frozenset({
66
+ "GotoAction",
67
+ "BreakLoop",
68
+ "ContinueLoop",
69
+ "EndWorkflow",
70
+ "EndDialog",
71
+ "EndConversation",
72
+ "CancelDialog",
73
+ "CancelAllDialogs",
74
+ })
75
+
76
+ # Required fields for specific action kinds (schema validation)
77
+ # Each action needs at least one of the listed fields (checked with alternates)
78
+ ACTION_REQUIRED_FIELDS: dict[str, list[str]] = {
79
+ "SetValue": ["path"],
80
+ "SetVariable": ["variable"],
81
+ "SendActivity": ["activity"],
82
+ "InvokeAzureAgent": ["agent"],
83
+ "GotoAction": ["actionId"],
84
+ "Foreach": ["source", "actions"],
85
+ "If": ["condition"],
86
+ "ConditionGroup": ["conditions"],
87
+ "Question": ["question", "variable"],
88
+ "RequestExternalInput": ["prompt", "variable"],
89
+ "RequestHumanInput": ["variable"],
90
+ "WaitForHumanInput": ["variable"],
91
+ "InvokeFunctionTool": ["functionName"],
92
+ "HttpRequestAction": ["url"],
93
+ "InvokeMcpTool": ["serverUrl", "toolName"],
94
+ }
95
+
96
+ # Alternate field names that satisfy required field requirements
97
+ # Key: "ActionKind.field", Value: list of alternates that satisfy the requirement
98
+ ACTION_ALTERNATE_FIELDS: dict[str, list[str]] = {
99
+ "SetValue.path": ["variable"],
100
+ "GotoAction.actionId": ["target"],
101
+ "InvokeAzureAgent.agent": ["agentName"],
102
+ # Top-level alternates that satisfy the nested-shape requirements without forcing
103
+ # callers to spell every field in its long form.
104
+ "Question.question": ["text"],
105
+ "Question.variable": ["property"],
106
+ "RequestExternalInput.prompt": ["message"],
107
+ "RequestExternalInput.variable": ["property"],
108
+ }
109
+
110
+
111
+ class DeclarativeWorkflowBuilder:
112
+ """Builds a Workflow graph from declarative YAML actions.
113
+
114
+ This builder transforms declarative action definitions into a proper
115
+ workflow graph with executor nodes and edges. It handles:
116
+ - Sequential actions (simple edges)
117
+ - Conditional branching (If/ConditionGroup with condition edges)
118
+ - Loops (Foreach with loop edges)
119
+ - Jumps (GotoAction with target edges)
120
+
121
+ Example usage:
122
+ yaml_def = {
123
+ "actions": [
124
+ {"kind": "SendActivity", "activity": {"text": "Hello"}},
125
+ {"kind": "SetValue", "path": "turn.count", "value": 0},
126
+ ]
127
+ }
128
+ builder = DeclarativeWorkflowBuilder(yaml_def)
129
+ workflow = builder.build()
130
+ """
131
+
132
+ def __init__(
133
+ self,
134
+ yaml_definition: dict[str, Any],
135
+ workflow_id: str | None = None,
136
+ agents: dict[str, Any] | None = None,
137
+ tools: dict[str, Any] | None = None,
138
+ checkpoint_storage: Any | None = None,
139
+ validate: bool = True,
140
+ max_iterations: int | None = None,
141
+ http_request_handler: HttpRequestHandler | None = None,
142
+ mcp_tool_handler: MCPToolHandler | None = None,
143
+ env_config: DeclarativeEnvConfig | None = None,
144
+ ):
145
+ """Initialize the builder.
146
+
147
+ Args:
148
+ yaml_definition: The parsed YAML workflow definition
149
+ workflow_id: Optional ID for the workflow (defaults to name from YAML)
150
+ agents: Registry of agent instances by name (for InvokeAzureAgent actions)
151
+ tools: Registry of tool/function instances by name (for InvokeFunctionTool actions)
152
+ checkpoint_storage: Optional checkpoint storage for pause/resume support
153
+ validate: Whether to validate the workflow definition before building (default: True)
154
+ max_iterations: Maximum runner supersteps. Falls back to the YAML ``maxTurns``
155
+ field, then to the core default (100).
156
+ http_request_handler: Handler used to dispatch HttpRequestAction requests.
157
+ Must be supplied when the workflow contains any HttpRequestAction;
158
+ otherwise build raises ``DeclarativeWorkflowError``.
159
+ mcp_tool_handler: Handler used to dispatch InvokeMcpTool calls.
160
+ Must be supplied when the workflow contains any InvokeMcpTool;
161
+ otherwise build raises ``DeclarativeWorkflowError``.
162
+ env_config: Optional :class:`DeclarativeEnvConfig` controlling
163
+ how the ``Env`` PowerFx symbol is populated for every
164
+ executor built by this builder. Defaults to an empty
165
+ configuration (``Env`` not exposed).
166
+ """
167
+ self._yaml_def = yaml_definition
168
+ self._workflow_id = workflow_id or yaml_definition.get("name", "declarative_workflow")
169
+ self._executors: dict[str, Any] = {} # id -> executor
170
+ self._action_index = 0 # Counter for generating unique IDs
171
+ self._agents = agents or {} # Agent registry for agent executors
172
+ self._tools = tools or {} # Tool registry for tool executors
173
+ self._checkpoint_storage = checkpoint_storage
174
+ self._pending_gotos: list[tuple[Any, str]] = [] # (goto_executor, target_id)
175
+ self._validate = validate
176
+ self._seen_explicit_ids: set[str] = set() # Track explicit IDs for duplicate detection
177
+ self._http_request_handler = http_request_handler
178
+ self._mcp_tool_handler = mcp_tool_handler
179
+ self._env_config: DeclarativeEnvConfig = env_config if env_config is not None else DeclarativeEnvConfig()
180
+ # Resolve max_iterations: explicit arg > YAML maxTurns > core default
181
+ resolved = max_iterations if max_iterations is not None else yaml_definition.get("maxTurns")
182
+ if resolved is not None and (not isinstance(resolved, int) or resolved <= 0):
183
+ raise ValueError(f"Invalid max_iterations/maxTurns value: {resolved!r}. Expected a positive integer.")
184
+ self._max_iterations: int | None = resolved
185
+
186
+ def build(self) -> Workflow:
187
+ """Build the workflow graph.
188
+
189
+ Returns:
190
+ A Workflow instance with all executors wired together
191
+
192
+ Raises:
193
+ ValueError: If no actions are defined (empty workflow), or validation fails
194
+ """
195
+ actions = self._yaml_def.get("actions", [])
196
+ if not actions:
197
+ # Empty workflow - raise an error since we need at least one executor
198
+ raise ValueError("Cannot build workflow with no actions. At least one action is required.")
199
+
200
+ # Validate workflow definition before building
201
+ if self._validate:
202
+ self._validate_workflow(actions)
203
+
204
+ # Create a stable entry node as the start executor, then wire it to the first action.
205
+ # This avoids needing a placeholder since the entry executor isn't known until after
206
+ # _create_executors_for_actions runs (which itself needs the builder to add edges).
207
+ entry_node = JoinExecutor({"kind": "Entry"}, id="_workflow_entry")
208
+ self._executors[entry_node.id] = entry_node
209
+ builder_kwargs: dict[str, Any] = {
210
+ "start_executor": entry_node,
211
+ "name": self._workflow_id,
212
+ "checkpoint_storage": self._checkpoint_storage,
213
+ }
214
+ if self._max_iterations is not None:
215
+ builder_kwargs["max_iterations"] = self._max_iterations
216
+ builder = WorkflowBuilder(**builder_kwargs)
217
+
218
+ # Create all executors and wire sequential edges
219
+ first_executor = self._create_executors_for_actions(actions, builder)
220
+
221
+ if not first_executor:
222
+ raise ValueError("Failed to create any executors from actions.")
223
+
224
+ # Wire entry node to the first action (handles both regular and control flow targets)
225
+ self._add_sequential_edge(builder, entry_node, first_executor)
226
+
227
+ # Resolve pending gotos (back-edges for loops, forward-edges for jumps)
228
+ self._resolve_pending_gotos(builder)
229
+
230
+ # Stamp the resolved DeclarativeEnvConfig onto every executor so they
231
+ # expose the configured Env binding through their _get_state(). This
232
+ # happens after _create_executors_for_actions and _resolve_pending_gotos
233
+ # so it covers the entry node, join nodes, evaluators, foreach
234
+ # init/next/exit nodes, and goto placeholders.
235
+ for executor in self._executors.values():
236
+ if isinstance(executor, DeclarativeActionExecutor):
237
+ executor.set_declarative_env_config(self._env_config)
238
+
239
+ return builder.build()
240
+
241
+ def _validate_workflow(self, actions: list[dict[str, Any]]) -> None:
242
+ """Validate the workflow definition before building.
243
+
244
+ Performs:
245
+ - Schema validation (required fields for action types)
246
+ - Duplicate explicit action ID detection
247
+ - Circular goto reference detection
248
+
249
+ Args:
250
+ actions: List of action definitions to validate
251
+
252
+ Raises:
253
+ ValueError: If validation fails
254
+ """
255
+ seen_ids: set[str] = set()
256
+ goto_targets: list[tuple[str, str | None]] = [] # (target_id, source_id)
257
+ defined_ids: set[str] = set()
258
+
259
+ # Collect all defined IDs and validate each action
260
+ self._validate_actions_recursive(actions, seen_ids, goto_targets, defined_ids)
261
+
262
+ # Check for circular goto chains (A -> B -> A)
263
+ # Build a simple graph of goto targets
264
+ self._validate_no_circular_gotos(goto_targets, defined_ids)
265
+
266
+ def _validate_actions_recursive(
267
+ self,
268
+ actions: list[dict[str, Any]],
269
+ seen_ids: set[str],
270
+ goto_targets: list[tuple[str, str | None]],
271
+ defined_ids: set[str],
272
+ ) -> None:
273
+ """Recursively validate actions and collect metadata.
274
+
275
+ Args:
276
+ actions: List of action definitions
277
+ seen_ids: Set of seen explicit IDs (for duplicate detection)
278
+ goto_targets: List of (target_id, source_id) tuples for goto validation
279
+ defined_ids: Set of all defined action IDs
280
+ """
281
+ for action_def in actions:
282
+ kind = action_def.get("kind", "")
283
+
284
+ # Check for duplicate or reserved explicit IDs
285
+ explicit_id = action_def.get("id")
286
+ if explicit_id:
287
+ if explicit_id == "_workflow_entry":
288
+ raise ValueError(f"Action ID '{explicit_id}' is reserved for internal use. Choose a different ID.")
289
+ if explicit_id in seen_ids:
290
+ raise ValueError(f"Duplicate action ID '{explicit_id}'. Action IDs must be unique.")
291
+ seen_ids.add(explicit_id)
292
+ defined_ids.add(explicit_id)
293
+
294
+ # Schema validation: check required fields
295
+ required_fields = ACTION_REQUIRED_FIELDS.get(kind, [])
296
+ for field in required_fields:
297
+ if field not in action_def and not self._has_alternate_field(action_def, kind, field):
298
+ raise ValueError(f"Action '{kind}' is missing required field '{field}'. Action: {action_def}")
299
+
300
+ # Collect goto targets for circular reference detection
301
+ if kind == "GotoAction":
302
+ target = action_def.get("target") or action_def.get("actionId")
303
+ if target:
304
+ goto_targets.append((target, explicit_id))
305
+
306
+ # Recursively validate nested actions
307
+ if kind == "If":
308
+ then_actions = action_def.get("then", action_def.get("actions", []))
309
+ if then_actions:
310
+ self._validate_actions_recursive(then_actions, seen_ids, goto_targets, defined_ids)
311
+ else_actions = action_def.get("else", [])
312
+ if else_actions:
313
+ self._validate_actions_recursive(else_actions, seen_ids, goto_targets, defined_ids)
314
+
315
+ elif kind == "ConditionGroup":
316
+ for forbidden in ("else", "default"):
317
+ if forbidden in action_def:
318
+ raise ValueError(
319
+ f"Action 'ConditionGroup' field '{forbidden}' is not supported; use 'elseActions' instead."
320
+ )
321
+ conditions = action_def.get("conditions", [])
322
+ for condition_branch in conditions:
323
+ branch_actions = condition_branch.get("actions", [])
324
+ if branch_actions:
325
+ self._validate_actions_recursive(branch_actions, seen_ids, goto_targets, defined_ids)
326
+ else_actions = action_def.get("elseActions", [])
327
+ if else_actions:
328
+ self._validate_actions_recursive(else_actions, seen_ids, goto_targets, defined_ids)
329
+
330
+ elif kind == "Foreach":
331
+ body_actions = action_def.get("actions", [])
332
+ if body_actions:
333
+ self._validate_actions_recursive(body_actions, seen_ids, goto_targets, defined_ids)
334
+
335
+ def _has_alternate_field(self, action_def: dict[str, Any], kind: str, field: str) -> bool:
336
+ """Check if an action has an alternate field that satisfies the requirement.
337
+
338
+ Some actions support multiple field names for the same purpose.
339
+
340
+ Args:
341
+ action_def: The action definition
342
+ kind: The action kind
343
+ field: The required field name
344
+
345
+ Returns:
346
+ True if an alternate field exists
347
+ """
348
+ key = f"{kind}.{field}"
349
+ return any(alt in action_def for alt in ACTION_ALTERNATE_FIELDS.get(key, []))
350
+
351
+ def _validate_no_circular_gotos(
352
+ self,
353
+ goto_targets: list[tuple[str, str | None]],
354
+ defined_ids: set[str],
355
+ ) -> None:
356
+ """Validate that there are no problematic circular goto chains.
357
+
358
+ Note: Some circular references are valid (e.g., loop-back patterns).
359
+ This checks for direct self-references only as a basic validation.
360
+
361
+ Args:
362
+ goto_targets: List of (target_id, source_id) tuples
363
+ defined_ids: Set of defined action IDs
364
+ """
365
+ for target_id, source_id in goto_targets:
366
+ # Check for direct self-reference
367
+ if source_id and target_id == source_id:
368
+ raise ValueError(
369
+ f"Action '{source_id}' has a direct self-referencing GotoAction, "
370
+ "which would cause an infinite loop."
371
+ )
372
+
373
+ def _resolve_pending_gotos(self, builder: WorkflowBuilder) -> None:
374
+ """Resolve pending goto edges after all executors are created.
375
+
376
+ Creates edges from goto executors to their target executors.
377
+
378
+ Raises:
379
+ ValueError: If a goto target references an action ID that does not exist.
380
+ """
381
+ for goto_executor, target_id in self._pending_gotos:
382
+ target_executor = self._executors.get(target_id)
383
+ if target_executor:
384
+ # Create edge from goto to target
385
+ builder.add_edge(source=goto_executor, target=target_executor)
386
+ else:
387
+ available_ids = list(self._executors.keys())
388
+ raise ValueError(f"GotoAction target '{target_id}' not found. Available action IDs: {available_ids}")
389
+
390
+ def _create_executors_for_actions(
391
+ self,
392
+ actions: list[dict[str, Any]],
393
+ builder: WorkflowBuilder,
394
+ parent_context: dict[str, Any] | None = None,
395
+ ) -> Any | None:
396
+ """Create executors for a list of actions and wire them together.
397
+
398
+ Args:
399
+ actions: List of action definitions
400
+ builder: The workflow builder
401
+ parent_context: Context from parent (e.g., loop info)
402
+
403
+ Returns:
404
+ The first executor in the chain, or None if no actions
405
+ """
406
+ if not actions:
407
+ return None
408
+
409
+ first_executor = None
410
+ prev_executor = None
411
+ executors_in_chain: list[Any] = []
412
+
413
+ for action_def in actions:
414
+ executor = self._create_executor_for_action(action_def, builder, parent_context)
415
+
416
+ if executor is None:
417
+ continue
418
+
419
+ executors_in_chain.append(executor)
420
+
421
+ if first_executor is None:
422
+ first_executor = executor
423
+
424
+ # Wire sequential edge from previous executor
425
+ if prev_executor is not None:
426
+ self._add_sequential_edge(builder, prev_executor, executor)
427
+
428
+ # Check if this action is a terminator (transfers control elsewhere)
429
+ # Terminators should not have fall-through edges to subsequent actions
430
+ action_kind = action_def.get("kind", "")
431
+ # Don't wire terminators to the next action - control flow ends there
432
+ prev_executor = None if action_kind in TERMINATOR_ACTIONS else executor
433
+
434
+ # Store the chain for later reference
435
+ if first_executor is not None:
436
+ first_executor._chain_executors = executors_in_chain
437
+
438
+ return first_executor
439
+
440
+ def _create_executor_for_action(
441
+ self,
442
+ action_def: dict[str, Any],
443
+ builder: WorkflowBuilder,
444
+ parent_context: dict[str, Any] | None = None,
445
+ ) -> Any | None:
446
+ """Create an executor for a single action.
447
+
448
+ Args:
449
+ action_def: The action definition from YAML
450
+ builder: The workflow builder
451
+ parent_context: Context from parent
452
+
453
+ Returns:
454
+ The created executor, or None if action type not supported
455
+ """
456
+ kind = action_def.get("kind", "")
457
+
458
+ # Handle special control flow actions
459
+ if kind == "If":
460
+ return self._create_if_structure(action_def, builder, parent_context)
461
+ if kind == "ConditionGroup":
462
+ return self._create_condition_group_structure(action_def, builder, parent_context)
463
+ if kind == "Foreach":
464
+ return self._create_foreach_structure(action_def, builder, parent_context)
465
+ if kind == "GotoAction":
466
+ return self._create_goto_reference(action_def, builder, parent_context)
467
+ if kind == "BreakLoop":
468
+ return self._create_break_executor(action_def, builder, parent_context)
469
+ if kind == "ContinueLoop":
470
+ return self._create_continue_executor(action_def, builder, parent_context)
471
+
472
+ # Get the executor class for this action kind
473
+ executor_class = ALL_ACTION_EXECUTORS.get(kind)
474
+
475
+ if executor_class is None:
476
+ # Unknown action type - log warning and skip
477
+ logger.warning(
478
+ "Unknown action kind '%s' encountered at index %d - action will be skipped. Available action kinds: %s",
479
+ kind,
480
+ self._action_index,
481
+ list(ALL_ACTION_EXECUTORS.keys()),
482
+ )
483
+ return None
484
+
485
+ # Create the executor with ID
486
+ # Priority: explicit ID from YAML > index-based ID (matches .NET behavior)
487
+ explicit_id = action_def.get("id")
488
+ if explicit_id:
489
+ action_id = explicit_id
490
+ else:
491
+ parent_id = (parent_context or {}).get("parent_id")
492
+ action_id = f"{parent_id}_{kind}_{self._action_index}" if parent_id else f"{kind}_{self._action_index}"
493
+ self._action_index += 1
494
+
495
+ # Pass agents/tools to specialized executors
496
+ executor: Any
497
+ if kind in ("InvokeAzureAgent",):
498
+ executor = InvokeAzureAgentExecutor(action_def, id=action_id, agents=self._agents)
499
+ elif kind == "InvokeFunctionTool":
500
+ executor = InvokeFunctionToolExecutor(action_def, id=action_id, tools=self._tools)
501
+ elif kind == "HttpRequestAction":
502
+ if self._http_request_handler is None:
503
+ raise DeclarativeWorkflowError(
504
+ f"Workflow defines HttpRequestAction '{action_id}' but no "
505
+ "http_request_handler was supplied to WorkflowFactory. Pass "
506
+ "http_request_handler=DefaultHttpRequestHandler() (or a custom "
507
+ "implementation) to enable HTTP requests."
508
+ )
509
+ executor = HttpRequestActionExecutor(
510
+ action_def,
511
+ id=action_id,
512
+ http_request_handler=self._http_request_handler,
513
+ )
514
+ elif kind == "InvokeMcpTool":
515
+ if self._mcp_tool_handler is None:
516
+ raise DeclarativeWorkflowError(
517
+ f"Workflow defines InvokeMcpTool '{action_id}' but no "
518
+ "mcp_tool_handler was supplied to WorkflowFactory. Pass "
519
+ "mcp_tool_handler=DefaultMCPToolHandler() (or a custom "
520
+ "implementation) to enable MCP tool invocations."
521
+ )
522
+ executor = InvokeMcpToolActionExecutor(
523
+ action_def,
524
+ id=action_id,
525
+ mcp_tool_handler=self._mcp_tool_handler,
526
+ )
527
+ else:
528
+ executor = executor_class(action_def, id=action_id)
529
+ self._executors[action_id] = executor
530
+
531
+ return executor
532
+
533
+ def _create_if_structure(
534
+ self,
535
+ action_def: dict[str, Any],
536
+ builder: WorkflowBuilder,
537
+ parent_context: dict[str, Any] | None = None,
538
+ ) -> Any:
539
+ """Create the graph structure for an If action.
540
+
541
+ An If action is implemented with a condition evaluator executor that
542
+ outputs a ConditionResult. Edge conditions check the branch_index to
543
+ route to either the then or else branch. This ensures first-match
544
+ semantics (only one branch executes).
545
+
546
+ Args:
547
+ action_def: The If action definition
548
+ builder: The workflow builder
549
+ parent_context: Context from parent
550
+
551
+ Returns:
552
+ A structure representing the If with evaluator, branch entries and exits
553
+ """
554
+ action_id = action_def.get("id") or f"If_{self._action_index}"
555
+ self._action_index += 1
556
+
557
+ condition_expr = action_def.get("condition", "true")
558
+ # Normalize boolean conditions from YAML to PowerFx-style strings
559
+ if condition_expr is True:
560
+ condition_expr = "=true"
561
+ elif condition_expr is False:
562
+ condition_expr = "=false"
563
+ elif isinstance(condition_expr, str) and not condition_expr.startswith("="):
564
+ # Bare string conditions should be evaluated as expressions
565
+ condition_expr = f"={condition_expr}"
566
+
567
+ # Pass the If's ID as context for child action naming
568
+ branch_context = {
569
+ **(parent_context or {}),
570
+ "parent_id": action_id,
571
+ }
572
+
573
+ # Create the condition evaluator executor
574
+ evaluator = IfConditionEvaluatorExecutor(
575
+ action_def,
576
+ condition_expr,
577
+ id=f"{action_id}_eval",
578
+ )
579
+ self._executors[evaluator.id] = evaluator
580
+
581
+ # Create then branch
582
+ then_actions = action_def.get("then", action_def.get("actions", []))
583
+ then_entry = self._create_executors_for_actions(then_actions, builder, branch_context)
584
+
585
+ # Create else branch
586
+ else_actions = action_def.get("else", [])
587
+ else_entry = self._create_executors_for_actions(else_actions, builder, branch_context) if else_actions else None
588
+ else_passthrough = None
589
+ if not else_entry:
590
+ # No else branch - create a passthrough for continuation when condition is false
591
+ else_passthrough = JoinExecutor({"kind": "ElsePassthrough"}, id=f"{action_id}_else_pass")
592
+ self._executors[else_passthrough.id] = else_passthrough
593
+
594
+ # Wire evaluator to branches with conditions that check ConditionResult.branch_index
595
+ # branch_index=0 means "then" branch, branch_index=-1 (ELSE_BRANCH_INDEX) means "else"
596
+ # For nested If/ConditionGroup structures, wire to the evaluator (entry point)
597
+ if then_entry:
598
+ then_target = self._get_structure_entry(then_entry)
599
+ builder.add_edge(
600
+ source=evaluator,
601
+ target=then_target,
602
+ condition=lambda msg: isinstance(msg, ConditionResult) and msg.branch_index == 0,
603
+ )
604
+ if else_entry:
605
+ else_target = self._get_structure_entry(else_entry)
606
+ builder.add_edge(
607
+ source=evaluator,
608
+ target=else_target,
609
+ condition=lambda msg: isinstance(msg, ConditionResult) and msg.branch_index == ELSE_BRANCH_INDEX,
610
+ )
611
+ elif else_passthrough:
612
+ builder.add_edge(
613
+ source=evaluator,
614
+ target=else_passthrough,
615
+ condition=lambda msg: isinstance(msg, ConditionResult) and msg.branch_index == ELSE_BRANCH_INDEX,
616
+ )
617
+
618
+ # Get branch exit executors for later wiring to successor
619
+ then_exit = self._get_branch_exit(then_entry)
620
+ else_exit = self._get_branch_exit(else_entry) if else_entry else else_passthrough
621
+
622
+ # Collect all branch exits (for wiring to successor)
623
+ branch_exits: list[Any] = []
624
+ if then_exit:
625
+ branch_exits.append(then_exit)
626
+ if else_exit:
627
+ branch_exits.append(else_exit)
628
+
629
+ # Create an IfStructure to hold all the info needed for wiring
630
+ class IfStructure:
631
+ def __init__(self) -> None:
632
+ self.id = action_id
633
+ self.evaluator = evaluator # The entry point for this structure
634
+ self.then_entry = then_entry
635
+ self.else_entry = else_entry
636
+ self.else_passthrough = else_passthrough
637
+ self.branch_exits = branch_exits # All exits that need wiring to successor
638
+ self._is_if_structure = True
639
+
640
+ return IfStructure()
641
+
642
+ def _create_condition_group_structure(
643
+ self,
644
+ action_def: dict[str, Any],
645
+ builder: WorkflowBuilder,
646
+ parent_context: dict[str, Any] | None = None,
647
+ ) -> Any:
648
+ """Create the graph structure for a ConditionGroup action.
649
+
650
+ Evaluates the action's ``conditions`` in order; the first match
651
+ selects its ``actions`` branch. If none match, ``elseActions`` runs.
652
+ The structure exposes an evaluator entry point and the per-branch
653
+ entry/exit pairs used by the caller to wire downstream edges.
654
+
655
+ Args:
656
+ action_def: The ConditionGroup action definition
657
+ builder: The workflow builder
658
+ parent_context: Context from parent
659
+
660
+ Returns:
661
+ A ConditionGroupStructure containing branch info for wiring
662
+ """
663
+ action_id = action_def.get("id") or f"ConditionGroup_{self._action_index}"
664
+ self._action_index += 1
665
+
666
+ # Pass the ConditionGroup's ID as context for child action naming
667
+ branch_context = {
668
+ **(parent_context or {}),
669
+ "parent_id": action_id,
670
+ }
671
+
672
+ conditions = action_def.get("conditions", [])
673
+ evaluator: DeclarativeActionExecutor = ConditionGroupEvaluatorExecutor(
674
+ action_def,
675
+ conditions,
676
+ id=f"{action_id}_eval",
677
+ )
678
+
679
+ self._executors[evaluator.id] = evaluator
680
+
681
+ # Collect branches and create executors for each
682
+ branch_entries: list[tuple[int, Any]] = [] # (branch_index, entry_executor)
683
+ branch_exits: list[Any] = [] # All exits that need wiring to successor
684
+
685
+ for i, item in enumerate(conditions):
686
+ branch_actions = item.get("actions", [])
687
+ # Use branch-specific context
688
+ case_context = {**branch_context, "parent_id": f"{action_id}_case{i}"}
689
+ branch_entry = self._create_executors_for_actions(branch_actions, builder, case_context)
690
+
691
+ if branch_entry:
692
+ branch_entries.append((i, branch_entry))
693
+ # Track exit for later wiring
694
+ branch_exit = self._get_branch_exit(branch_entry)
695
+ if branch_exit:
696
+ branch_exits.append(branch_exit)
697
+
698
+ else_actions = action_def.get("elseActions", [])
699
+ default_entry = None
700
+ default_passthrough = None
701
+ if else_actions:
702
+ default_context = {**branch_context, "parent_id": f"{action_id}_else"}
703
+ default_entry = self._create_executors_for_actions(else_actions, builder, default_context)
704
+ if default_entry:
705
+ default_exit = self._get_branch_exit(default_entry)
706
+ if default_exit:
707
+ branch_exits.append(default_exit)
708
+ else:
709
+ # No else actions - create a passthrough for the "no match" case
710
+ # This allows the workflow to continue to the next action when no condition matches
711
+ default_passthrough = JoinExecutor({"kind": "DefaultPassthrough"}, id=f"{action_id}_default")
712
+ self._executors[default_passthrough.id] = default_passthrough
713
+ branch_exits.append(default_passthrough)
714
+
715
+ # Wire evaluator to branches with conditions that check ConditionResult.branch_index
716
+ # For nested If/ConditionGroup structures, wire to the evaluator (entry point)
717
+ for branch_index, branch_entry in branch_entries:
718
+ # Capture branch_index in closure properly using a factory function for type inference
719
+ def make_branch_condition(expected: int) -> Any:
720
+ return lambda msg: isinstance(msg, ConditionResult) and msg.branch_index == expected # type: ignore
721
+
722
+ branch_target = self._get_structure_entry(branch_entry)
723
+ builder.add_edge(
724
+ source=evaluator,
725
+ target=branch_target,
726
+ condition=make_branch_condition(branch_index),
727
+ )
728
+
729
+ # Wire evaluator to default/else branch
730
+ if default_entry:
731
+ default_target = self._get_structure_entry(default_entry)
732
+ builder.add_edge(
733
+ source=evaluator,
734
+ target=default_target,
735
+ condition=lambda msg: isinstance(msg, ConditionResult) and msg.branch_index == ELSE_BRANCH_INDEX,
736
+ )
737
+ elif default_passthrough:
738
+ builder.add_edge(
739
+ source=evaluator,
740
+ target=default_passthrough,
741
+ condition=lambda msg: isinstance(msg, ConditionResult) and msg.branch_index == ELSE_BRANCH_INDEX,
742
+ )
743
+
744
+ # Create a ConditionGroupStructure to hold all the info needed for wiring
745
+ class ConditionGroupStructure:
746
+ def __init__(self) -> None:
747
+ self.id = action_id
748
+ self.evaluator = evaluator # The entry point for this structure
749
+ self.branch_entries = branch_entries
750
+ self.default_entry = default_entry
751
+ self.default_passthrough = default_passthrough
752
+ self.branch_exits = branch_exits # All exits that need wiring to successor
753
+ self._is_condition_group_structure = True
754
+
755
+ return ConditionGroupStructure()
756
+
757
+ def _create_foreach_structure(
758
+ self,
759
+ action_def: dict[str, Any],
760
+ builder: WorkflowBuilder,
761
+ parent_context: dict[str, Any] | None = None,
762
+ ) -> Any:
763
+ """Create the graph structure for a Foreach action.
764
+
765
+ A Foreach action becomes:
766
+ 1. ForeachInit node that initializes the loop
767
+ 2. Loop body actions
768
+ 3. ForeachNext node that advances to next item
769
+ 4. Back-edge from ForeachNext to loop body (when has_next=True)
770
+ 5. Exit edge from ForeachNext (when has_next=False)
771
+
772
+ Args:
773
+ action_def: The Foreach action definition
774
+ builder: The workflow builder
775
+ parent_context: Context from parent
776
+
777
+ Returns:
778
+ The foreach init executor (entry point)
779
+ """
780
+ action_id = action_def.get("id") or f"Foreach_{self._action_index}"
781
+ self._action_index += 1
782
+
783
+ # Create foreach init executor
784
+ init_executor = ForeachInitExecutor(action_def, id=f"{action_id}_init")
785
+ self._executors[init_executor.id] = init_executor
786
+
787
+ # Create foreach next executor (for advancing to next item)
788
+ next_executor = ForeachNextExecutor(action_def, init_executor.id, id=f"{action_id}_next")
789
+ self._executors[next_executor.id] = next_executor
790
+
791
+ # Create join node for loop exit
792
+ join_executor = JoinExecutor({"kind": "Join"}, id=f"{action_id}_exit")
793
+ self._executors[join_executor.id] = join_executor
794
+
795
+ # Create loop body
796
+ body_actions = action_def.get("actions", [])
797
+ loop_context = {
798
+ **(parent_context or {}),
799
+ "loop_id": action_id,
800
+ "loop_next_executor": next_executor,
801
+ }
802
+ body_entry = self._create_executors_for_actions(body_actions, builder, loop_context)
803
+
804
+ if body_entry:
805
+ # For nested If/ConditionGroup structures, wire to the evaluator (entry point)
806
+ body_target = self._get_structure_entry(body_entry)
807
+
808
+ # Init -> body (when has_next=True)
809
+ builder.add_edge(
810
+ source=init_executor,
811
+ target=body_target,
812
+ condition=lambda msg: isinstance(msg, LoopIterationResult) and msg.has_next,
813
+ )
814
+
815
+ # Wire from the LAST body action so the loop only advances after the
816
+ # whole body completes. _get_branch_exit walks the chain, skips
817
+ # terminators (Break/Continue), and returns nested If/ConditionGroup
818
+ # structures so _get_source_exits can flatten their branch exits.
819
+ body_exit = self._get_branch_exit(body_entry)
820
+ if body_exit is not None:
821
+ for source_exit in self._get_source_exits(body_exit):
822
+ builder.add_edge(source=source_exit, target=next_executor)
823
+
824
+ # Next -> body (when has_next=True, loop back)
825
+ builder.add_edge(
826
+ source=next_executor,
827
+ target=body_target,
828
+ condition=lambda msg: isinstance(msg, LoopIterationResult) and msg.has_next,
829
+ )
830
+
831
+ # Init -> join (when has_next=False, empty collection)
832
+ builder.add_edge(
833
+ source=init_executor,
834
+ target=join_executor,
835
+ condition=lambda msg: isinstance(msg, LoopIterationResult) and not msg.has_next,
836
+ )
837
+
838
+ # Next -> join (when has_next=False, loop complete)
839
+ builder.add_edge(
840
+ source=next_executor,
841
+ target=join_executor,
842
+ condition=lambda msg: isinstance(msg, LoopIterationResult) and not msg.has_next,
843
+ )
844
+
845
+ init_executor._exit_executor = join_executor # type: ignore[attr-defined]
846
+ return init_executor
847
+
848
+ def _create_goto_reference(
849
+ self,
850
+ action_def: dict[str, Any],
851
+ builder: WorkflowBuilder,
852
+ parent_context: dict[str, Any] | None = None,
853
+ ) -> Any | None:
854
+ """Create a GotoAction executor that jumps to the target action.
855
+
856
+ GotoAction creates a back-edge (or forward-edge) in the graph to the target action.
857
+ We create a pass-through executor and record the pending edge to be resolved
858
+ after all executors are created.
859
+ """
860
+ from ._executors_control_flow import JoinExecutor
861
+
862
+ target_id = action_def.get("target") or action_def.get("actionId")
863
+
864
+ if not target_id:
865
+ return None
866
+
867
+ # Create a pass-through executor for the goto
868
+ action_id = action_def.get("id") or f"goto_{target_id}_{self._action_index}"
869
+ self._action_index += 1
870
+
871
+ # Use JoinExecutor as a simple pass-through node
872
+ goto_executor = JoinExecutor(action_def, id=action_id)
873
+ self._executors[action_id] = goto_executor
874
+
875
+ # Record pending goto edge to be resolved after all executors created
876
+ self._pending_gotos.append((goto_executor, target_id))
877
+
878
+ return goto_executor
879
+
880
+ def _create_break_executor(
881
+ self,
882
+ action_def: dict[str, Any],
883
+ builder: WorkflowBuilder,
884
+ parent_context: dict[str, Any] | None = None,
885
+ ) -> Any | None:
886
+ """Create a break executor for loop control.
887
+
888
+ Raises:
889
+ ValueError: If BreakLoop is used outside of a loop.
890
+ """
891
+ from ._executors_control_flow import BreakLoopExecutor
892
+
893
+ if parent_context and "loop_next_executor" in parent_context:
894
+ loop_next = parent_context["loop_next_executor"]
895
+ action_id = action_def.get("id") or f"Break_{self._action_index}"
896
+ self._action_index += 1
897
+
898
+ executor = BreakLoopExecutor(action_def, loop_next.id, id=action_id)
899
+ self._executors[action_id] = executor
900
+
901
+ # Wire break to loop next
902
+ builder.add_edge(source=executor, target=loop_next)
903
+
904
+ return executor
905
+
906
+ raise ValueError("BreakLoop action can only be used inside a Foreach loop")
907
+
908
+ def _create_continue_executor(
909
+ self,
910
+ action_def: dict[str, Any],
911
+ builder: WorkflowBuilder,
912
+ parent_context: dict[str, Any] | None = None,
913
+ ) -> Any | None:
914
+ """Create a continue executor for loop control.
915
+
916
+ Raises:
917
+ ValueError: If ContinueLoop is used outside of a loop.
918
+ """
919
+ from ._executors_control_flow import ContinueLoopExecutor
920
+
921
+ if parent_context and "loop_next_executor" in parent_context:
922
+ loop_next = parent_context["loop_next_executor"]
923
+ action_id = action_def.get("id") or f"Continue_{self._action_index}"
924
+ self._action_index += 1
925
+
926
+ executor = ContinueLoopExecutor(action_def, loop_next.id, id=action_id)
927
+ self._executors[action_id] = executor
928
+
929
+ # Wire continue to loop next
930
+ builder.add_edge(source=executor, target=loop_next)
931
+
932
+ return executor
933
+
934
+ raise ValueError("ContinueLoop action can only be used inside a Foreach loop")
935
+
936
+ def _add_sequential_edge(
937
+ self,
938
+ builder: WorkflowBuilder,
939
+ source: Any,
940
+ target: Any,
941
+ ) -> None:
942
+ """Add a sequential edge between two executors.
943
+
944
+ Handles control flow structures:
945
+ - If source is a structure (If/ConditionGroup), wire from all branch exits
946
+ - If target is a structure (If/ConditionGroup), wire with conditional edges to branches
947
+ """
948
+ # Get all source exit points
949
+ source_exits = self._get_source_exits(source)
950
+
951
+ # Wire each source exit to target
952
+ for source_exit in source_exits:
953
+ self._wire_to_target(builder, source_exit, target)
954
+
955
+ def _get_source_exits(self, source: Any) -> list[Any]:
956
+ """Get all exit executors from a source (handles structures with multiple exits)."""
957
+ # Check if source is a structure with branch_exits
958
+ if hasattr(source, "branch_exits"):
959
+ # Collect all exits, recursively flattening nested structures
960
+ all_exits: list[Any] = []
961
+ for exit_item in source.branch_exits:
962
+ if hasattr(exit_item, "branch_exits"):
963
+ # Nested structure - recurse
964
+ all_exits.extend(self._collect_all_exits(exit_item))
965
+ else:
966
+ all_exits.append(exit_item)
967
+ return all_exits if all_exits else []
968
+
969
+ # Check if source has a single exit executor
970
+ actual_exit = getattr(source, "_exit_executor", source)
971
+ return [actual_exit]
972
+
973
+ def _wire_to_target(
974
+ self,
975
+ builder: WorkflowBuilder,
976
+ source: Any,
977
+ target: Any,
978
+ ) -> None:
979
+ """Wire a single source executor to a target (which may be a structure).
980
+
981
+ For If/ConditionGroup structures, wire to the evaluator executor. The evaluator
982
+ handles condition evaluation and outputs ConditionResult, which is then
983
+ routed to the appropriate branch by edges created in _create_*_structure.
984
+ """
985
+ # Check if target is an IfStructure or ConditionGroupStructure (wire to evaluator)
986
+ if getattr(target, "_is_if_structure", False) or getattr(target, "_is_condition_group_structure", False):
987
+ # Wire from source to the evaluator - the evaluator then routes to branches
988
+ builder.add_edge(source=source, target=target.evaluator)
989
+
990
+ else:
991
+ # Normal sequential edge to a regular executor
992
+ builder.add_edge(source=source, target=target)
993
+
994
+ def _get_structure_entry(self, entry: Any) -> Any:
995
+ """Get the entry point executor for a structure or regular executor.
996
+
997
+ For If/ConditionGroup structures, returns the evaluator. For regular executors,
998
+ returns the executor itself.
999
+
1000
+ Args:
1001
+ entry: An executor or structure
1002
+
1003
+ Returns:
1004
+ The entry point executor
1005
+ """
1006
+ is_structure = getattr(entry, "_is_if_structure", False) or getattr(
1007
+ entry, "_is_condition_group_structure", False
1008
+ )
1009
+ return entry.evaluator if is_structure else entry
1010
+
1011
+ def _get_branch_exit(self, branch_entry: Any) -> Any | None:
1012
+ """Get the exit point of a branch for downstream wiring.
1013
+
1014
+ Returns the last executor (or its ``_exit_executor``) for a linear chain,
1015
+ the nested If/ConditionGroup structure itself when the chain ends in one (so
1016
+ callers can flatten ``branch_exits`` via :meth:`_get_source_exits`), or
1017
+ ``None`` when the branch is empty or ends in a terminator action.
1018
+ """
1019
+ if branch_entry is None:
1020
+ return None
1021
+
1022
+ # Get the chain of executors in this branch
1023
+ chain = getattr(branch_entry, "_chain_executors", [branch_entry])
1024
+
1025
+ last_executor = chain[-1]
1026
+
1027
+ # Skip terminators — they handle their own control flow
1028
+ action_def_obj = getattr(last_executor, "_action_def", {})
1029
+ action_def = cast(dict[str, Any], action_def_obj) if isinstance(action_def_obj, dict) else {}
1030
+ if action_def.get("kind", "") in TERMINATOR_ACTIONS:
1031
+ return None
1032
+
1033
+ # Check if last executor is a structure with branch_exits
1034
+ # In that case, we return the structure so its exits can be collected
1035
+ if hasattr(last_executor, "branch_exits"):
1036
+ return last_executor
1037
+
1038
+ # Regular executor - get its exit point
1039
+ return getattr(last_executor, "_exit_executor", last_executor)
1040
+
1041
+ def _collect_all_exits(self, structure: Any) -> list[Any]:
1042
+ """Recursively collect all exit executors from a structure."""
1043
+ exits: list[Any] = []
1044
+
1045
+ if not hasattr(structure, "branch_exits"):
1046
+ # Not a structure - return the executor itself
1047
+ actual_exit = getattr(structure, "_exit_executor", structure)
1048
+ return [actual_exit]
1049
+
1050
+ for exit_item in structure.branch_exits:
1051
+ if hasattr(exit_item, "branch_exits"):
1052
+ # Nested structure - recurse
1053
+ exits.extend(self._collect_all_exits(exit_item))
1054
+ else:
1055
+ exits.append(exit_item)
1056
+
1057
+ return exits