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.
- agent_framework_declarative/__init__.py +71 -0
- agent_framework_declarative/_loader.py +868 -0
- agent_framework_declarative/_models.py +1154 -0
- agent_framework_declarative/_workflows/__init__.py +167 -0
- agent_framework_declarative/_workflows/_declarative_base.py +1226 -0
- agent_framework_declarative/_workflows/_declarative_builder.py +1057 -0
- agent_framework_declarative/_workflows/_errors.py +38 -0
- agent_framework_declarative/_workflows/_executors_agents.py +1025 -0
- agent_framework_declarative/_workflows/_executors_basic.py +574 -0
- agent_framework_declarative/_workflows/_executors_control_flow.py +461 -0
- agent_framework_declarative/_workflows/_executors_external_input.py +243 -0
- agent_framework_declarative/_workflows/_executors_http.py +417 -0
- agent_framework_declarative/_workflows/_executors_mcp.py +549 -0
- agent_framework_declarative/_workflows/_executors_tools.py +660 -0
- agent_framework_declarative/_workflows/_factory.py +808 -0
- agent_framework_declarative/_workflows/_http_handler.py +237 -0
- agent_framework_declarative/_workflows/_mcp_handler.py +581 -0
- agent_framework_declarative/_workflows/_powerfx_functions.py +498 -0
- agent_framework_declarative/_workflows/_state.py +650 -0
- agent_framework_declarative-1.0.0.dist-info/METADATA +49 -0
- agent_framework_declarative-1.0.0.dist-info/RECORD +23 -0
- agent_framework_declarative-1.0.0.dist-info/WHEEL +4 -0
- agent_framework_declarative-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|