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,461 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""Control flow executors for the graph-based declarative workflow system.
|
|
4
|
+
|
|
5
|
+
Control flow in the graph-based system is handled differently than the interpreter:
|
|
6
|
+
- If/ConditionGroup: Condition evaluation happens in a dedicated evaluator executor that
|
|
7
|
+
returns a ConditionResult with the first-matching branch index. Edge conditions
|
|
8
|
+
then check the branch_index to route to the correct branch. This ensures only
|
|
9
|
+
one branch executes (first-match semantics), matching the interpreter behavior.
|
|
10
|
+
- Foreach: Loop iteration state managed in State + loop edges
|
|
11
|
+
- Goto: Edge to target action (handled by builder)
|
|
12
|
+
- Break/Continue: Special signals for loop control
|
|
13
|
+
|
|
14
|
+
The key insight is that control flow becomes GRAPH STRUCTURE, not executor logic.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from typing import Any, cast
|
|
18
|
+
|
|
19
|
+
from agent_framework import (
|
|
20
|
+
Message,
|
|
21
|
+
WorkflowContext,
|
|
22
|
+
handler,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from ._declarative_base import (
|
|
26
|
+
ActionComplete,
|
|
27
|
+
ActionTrigger,
|
|
28
|
+
ConditionResult,
|
|
29
|
+
DeclarativeActionExecutor,
|
|
30
|
+
LoopControl,
|
|
31
|
+
LoopIterationResult,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Keys for loop state in State
|
|
35
|
+
LOOP_STATE_KEY = "_declarative_loop_state"
|
|
36
|
+
|
|
37
|
+
# Index value indicating the else/default branch
|
|
38
|
+
ELSE_BRANCH_INDEX = -1
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConditionGroupEvaluatorExecutor(DeclarativeActionExecutor):
|
|
42
|
+
"""Evaluates conditions for ConditionGroup and outputs the first-matching branch.
|
|
43
|
+
|
|
44
|
+
This executor implements first-match semantics by evaluating conditions sequentially
|
|
45
|
+
and outputting a ConditionResult with the index of the first matching branch.
|
|
46
|
+
Edge conditions downstream check this index to route to the correct branch.
|
|
47
|
+
|
|
48
|
+
This mirrors .NET's ConditionGroupExecutor.ExecuteAsync which returns the step ID
|
|
49
|
+
of the first matching condition.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
action_def: dict[str, Any],
|
|
55
|
+
conditions: list[dict[str, Any]],
|
|
56
|
+
*,
|
|
57
|
+
id: str | None = None,
|
|
58
|
+
):
|
|
59
|
+
"""Initialize the condition evaluator.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
action_def: The ConditionGroup action definition
|
|
63
|
+
conditions: List of condition items, each with 'condition' and optional 'id'
|
|
64
|
+
id: Optional executor ID
|
|
65
|
+
"""
|
|
66
|
+
super().__init__(action_def, id=id)
|
|
67
|
+
self._conditions = conditions
|
|
68
|
+
|
|
69
|
+
@handler
|
|
70
|
+
async def handle_action(
|
|
71
|
+
self,
|
|
72
|
+
trigger: Any,
|
|
73
|
+
ctx: WorkflowContext[ConditionResult],
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Evaluate conditions and output the first matching branch index."""
|
|
76
|
+
state = await self._ensure_state_initialized(ctx, trigger)
|
|
77
|
+
|
|
78
|
+
# Evaluate conditions sequentially - first match wins
|
|
79
|
+
for index, cond_item in enumerate(self._conditions):
|
|
80
|
+
condition_expr = cond_item.get("condition")
|
|
81
|
+
if condition_expr is None:
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
# Normalize boolean conditions
|
|
85
|
+
if condition_expr is True:
|
|
86
|
+
condition_expr = "=true"
|
|
87
|
+
elif condition_expr is False:
|
|
88
|
+
condition_expr = "=false"
|
|
89
|
+
elif isinstance(condition_expr, str) and not condition_expr.startswith("="):
|
|
90
|
+
condition_expr = f"={condition_expr}"
|
|
91
|
+
|
|
92
|
+
result = state.eval(condition_expr)
|
|
93
|
+
if bool(result):
|
|
94
|
+
# First matching condition found
|
|
95
|
+
await ctx.send_message(ConditionResult(matched=True, branch_index=index, value=result))
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
# No condition matched - use else/default branch
|
|
99
|
+
await ctx.send_message(ConditionResult(matched=False, branch_index=ELSE_BRANCH_INDEX))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class IfConditionEvaluatorExecutor(DeclarativeActionExecutor):
|
|
103
|
+
"""Evaluates a single If condition and outputs a ConditionResult.
|
|
104
|
+
|
|
105
|
+
This is simpler than ConditionGroupEvaluator - just evaluates one condition
|
|
106
|
+
and outputs branch_index=0 (then) or branch_index=-1 (else).
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
action_def: dict[str, Any],
|
|
112
|
+
condition_expr: str,
|
|
113
|
+
*,
|
|
114
|
+
id: str | None = None,
|
|
115
|
+
):
|
|
116
|
+
"""Initialize the if condition evaluator.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
action_def: The If action definition
|
|
120
|
+
condition_expr: The condition expression to evaluate
|
|
121
|
+
id: Optional executor ID
|
|
122
|
+
"""
|
|
123
|
+
super().__init__(action_def, id=id)
|
|
124
|
+
self._condition_expr = condition_expr
|
|
125
|
+
|
|
126
|
+
@handler
|
|
127
|
+
async def handle_action(
|
|
128
|
+
self,
|
|
129
|
+
trigger: Any,
|
|
130
|
+
ctx: WorkflowContext[ConditionResult],
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Evaluate the condition and output the result."""
|
|
133
|
+
state = await self._ensure_state_initialized(ctx, trigger)
|
|
134
|
+
|
|
135
|
+
result = state.eval(self._condition_expr)
|
|
136
|
+
is_truthy = bool(result)
|
|
137
|
+
|
|
138
|
+
if is_truthy:
|
|
139
|
+
await ctx.send_message(ConditionResult(matched=True, branch_index=0, value=result))
|
|
140
|
+
else:
|
|
141
|
+
await ctx.send_message(ConditionResult(matched=False, branch_index=ELSE_BRANCH_INDEX, value=result))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ForeachInitExecutor(DeclarativeActionExecutor):
|
|
145
|
+
"""Initializes a foreach loop.
|
|
146
|
+
|
|
147
|
+
Sets up the loop state in State and determines if there are items.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
@handler
|
|
151
|
+
async def handle_action(
|
|
152
|
+
self,
|
|
153
|
+
trigger: Any,
|
|
154
|
+
ctx: WorkflowContext[LoopIterationResult],
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Initialize the loop and check for first item."""
|
|
157
|
+
state = await self._ensure_state_initialized(ctx, trigger)
|
|
158
|
+
|
|
159
|
+
items_expr = self._action_def.get("source")
|
|
160
|
+
items_raw: Any = state.eval_if_expression(items_expr) or []
|
|
161
|
+
|
|
162
|
+
items: list[Any]
|
|
163
|
+
items = (list(items_raw) if items_raw else []) if not isinstance(items_raw, (list, tuple)) else list(items_raw) # type: ignore
|
|
164
|
+
|
|
165
|
+
loop_id = self.id
|
|
166
|
+
|
|
167
|
+
# Store loop state
|
|
168
|
+
state_data = state.get_state_data()
|
|
169
|
+
loop_states: dict[str, Any] = cast(dict[str, Any], state_data).setdefault(LOOP_STATE_KEY, {})
|
|
170
|
+
loop_states[loop_id] = {
|
|
171
|
+
"items": items,
|
|
172
|
+
"index": 0,
|
|
173
|
+
"length": len(items),
|
|
174
|
+
}
|
|
175
|
+
state.set_state_data(state_data)
|
|
176
|
+
|
|
177
|
+
if items:
|
|
178
|
+
# Bind the current item and (when requested) the index under the Local scope.
|
|
179
|
+
item_var = f"Local.{self._action_def.get('itemName', 'item')}"
|
|
180
|
+
index_var = (
|
|
181
|
+
f"Local.{self._action_def.get('indexName', 'index')}" if "indexName" in self._action_def else None
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
state.set(item_var, items[0])
|
|
185
|
+
if index_var:
|
|
186
|
+
state.set(index_var, 0)
|
|
187
|
+
|
|
188
|
+
await ctx.send_message(LoopIterationResult(has_next=True, current_item=items[0], current_index=0))
|
|
189
|
+
else:
|
|
190
|
+
await ctx.send_message(LoopIterationResult(has_next=False))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class ForeachNextExecutor(DeclarativeActionExecutor):
|
|
194
|
+
"""Advances to the next item in a foreach loop.
|
|
195
|
+
|
|
196
|
+
This executor is triggered after the loop body completes.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
def __init__(
|
|
200
|
+
self,
|
|
201
|
+
action_def: dict[str, Any],
|
|
202
|
+
init_executor_id: str,
|
|
203
|
+
*,
|
|
204
|
+
id: str | None = None,
|
|
205
|
+
):
|
|
206
|
+
"""Initialize with reference to the init executor.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
action_def: The Foreach action definition
|
|
210
|
+
init_executor_id: ID of the corresponding ForeachInitExecutor
|
|
211
|
+
id: Optional executor ID
|
|
212
|
+
"""
|
|
213
|
+
super().__init__(action_def, id=id)
|
|
214
|
+
self._init_executor_id = init_executor_id
|
|
215
|
+
|
|
216
|
+
@handler
|
|
217
|
+
async def handle_action(
|
|
218
|
+
self,
|
|
219
|
+
trigger: Any,
|
|
220
|
+
ctx: WorkflowContext[LoopIterationResult],
|
|
221
|
+
) -> None:
|
|
222
|
+
"""Advance to next item and send result."""
|
|
223
|
+
state = await self._ensure_state_initialized(ctx, trigger)
|
|
224
|
+
|
|
225
|
+
loop_id = self._init_executor_id
|
|
226
|
+
|
|
227
|
+
# Get loop state
|
|
228
|
+
state_data = state.get_state_data()
|
|
229
|
+
loop_states: dict[str, Any] = cast(dict[str, Any], state_data).get(LOOP_STATE_KEY, {})
|
|
230
|
+
loop_state = loop_states.get(loop_id)
|
|
231
|
+
|
|
232
|
+
if not loop_state:
|
|
233
|
+
# No loop state - shouldn't happen but handle gracefully
|
|
234
|
+
await ctx.send_message(LoopIterationResult(has_next=False))
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
items = loop_state["items"]
|
|
238
|
+
current_index = loop_state["index"] + 1
|
|
239
|
+
|
|
240
|
+
if current_index < len(items):
|
|
241
|
+
# Update loop state
|
|
242
|
+
loop_state["index"] = current_index
|
|
243
|
+
state.set_state_data(state_data)
|
|
244
|
+
|
|
245
|
+
# Rebind the current item and (when requested) the index under the Local scope.
|
|
246
|
+
item_var = f"Local.{self._action_def.get('itemName', 'item')}"
|
|
247
|
+
index_var = (
|
|
248
|
+
f"Local.{self._action_def.get('indexName', 'index')}" if "indexName" in self._action_def else None
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
state.set(item_var, items[current_index])
|
|
252
|
+
if index_var:
|
|
253
|
+
state.set(index_var, current_index)
|
|
254
|
+
|
|
255
|
+
await ctx.send_message(
|
|
256
|
+
LoopIterationResult(has_next=True, current_item=items[current_index], current_index=current_index)
|
|
257
|
+
)
|
|
258
|
+
else:
|
|
259
|
+
# Loop complete - clean up
|
|
260
|
+
loop_states_dict = cast(dict[str, Any], state_data).get(LOOP_STATE_KEY, {})
|
|
261
|
+
if loop_id in loop_states_dict:
|
|
262
|
+
del loop_states_dict[loop_id]
|
|
263
|
+
state.set_state_data(state_data)
|
|
264
|
+
|
|
265
|
+
await ctx.send_message(LoopIterationResult(has_next=False))
|
|
266
|
+
|
|
267
|
+
@handler
|
|
268
|
+
async def handle_loop_control(
|
|
269
|
+
self,
|
|
270
|
+
control: LoopControl,
|
|
271
|
+
ctx: WorkflowContext[LoopIterationResult],
|
|
272
|
+
) -> None:
|
|
273
|
+
"""Handle break/continue signals."""
|
|
274
|
+
state = self._get_state(ctx.state)
|
|
275
|
+
|
|
276
|
+
if control.action == "break":
|
|
277
|
+
# Clean up loop state and signal done
|
|
278
|
+
state_data = state.get_state_data()
|
|
279
|
+
loop_states: dict[str, Any] = cast(dict[str, Any], state_data).get(LOOP_STATE_KEY, {})
|
|
280
|
+
if self._init_executor_id in loop_states:
|
|
281
|
+
del loop_states[self._init_executor_id]
|
|
282
|
+
state.set_state_data(state_data)
|
|
283
|
+
|
|
284
|
+
await ctx.send_message(LoopIterationResult(has_next=False))
|
|
285
|
+
|
|
286
|
+
elif control.action == "continue":
|
|
287
|
+
# Just advance to next iteration
|
|
288
|
+
await self.handle_action(ActionTrigger(), ctx)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class BreakLoopExecutor(DeclarativeActionExecutor):
|
|
292
|
+
"""Executor for BreakLoop action.
|
|
293
|
+
|
|
294
|
+
Sends a LoopControl signal to break out of the enclosing loop.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
def __init__(
|
|
298
|
+
self,
|
|
299
|
+
action_def: dict[str, Any],
|
|
300
|
+
loop_next_executor_id: str,
|
|
301
|
+
*,
|
|
302
|
+
id: str | None = None,
|
|
303
|
+
):
|
|
304
|
+
"""Initialize with reference to the loop's next executor.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
action_def: The action definition
|
|
308
|
+
loop_next_executor_id: ID of the ForeachNextExecutor to signal
|
|
309
|
+
id: Optional executor ID
|
|
310
|
+
"""
|
|
311
|
+
super().__init__(action_def, id=id)
|
|
312
|
+
self._loop_next_executor_id = loop_next_executor_id
|
|
313
|
+
|
|
314
|
+
@handler
|
|
315
|
+
async def handle_action(
|
|
316
|
+
self,
|
|
317
|
+
trigger: Any,
|
|
318
|
+
ctx: WorkflowContext[LoopControl],
|
|
319
|
+
) -> None:
|
|
320
|
+
"""Send break signal to the loop."""
|
|
321
|
+
await ctx.send_message(LoopControl(action="break"))
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class ContinueLoopExecutor(DeclarativeActionExecutor):
|
|
325
|
+
"""Executor for ContinueLoop action.
|
|
326
|
+
|
|
327
|
+
Sends a LoopControl signal to continue to next iteration.
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
def __init__(
|
|
331
|
+
self,
|
|
332
|
+
action_def: dict[str, Any],
|
|
333
|
+
loop_next_executor_id: str,
|
|
334
|
+
*,
|
|
335
|
+
id: str | None = None,
|
|
336
|
+
):
|
|
337
|
+
"""Initialize with reference to the loop's next executor.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
action_def: The action definition
|
|
341
|
+
loop_next_executor_id: ID of the ForeachNextExecutor to signal
|
|
342
|
+
id: Optional executor ID
|
|
343
|
+
"""
|
|
344
|
+
super().__init__(action_def, id=id)
|
|
345
|
+
self._loop_next_executor_id = loop_next_executor_id
|
|
346
|
+
|
|
347
|
+
@handler
|
|
348
|
+
async def handle_action(
|
|
349
|
+
self,
|
|
350
|
+
trigger: Any,
|
|
351
|
+
ctx: WorkflowContext[LoopControl],
|
|
352
|
+
) -> None:
|
|
353
|
+
"""Send continue signal to the loop."""
|
|
354
|
+
await ctx.send_message(LoopControl(action="continue"))
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class EndWorkflowExecutor(DeclarativeActionExecutor):
|
|
358
|
+
"""Executor for EndWorkflow/EndDialog action.
|
|
359
|
+
|
|
360
|
+
This executor simply doesn't send any message, causing the workflow
|
|
361
|
+
to terminate at this point.
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
@handler
|
|
365
|
+
async def handle_action(
|
|
366
|
+
self,
|
|
367
|
+
trigger: Any,
|
|
368
|
+
ctx: WorkflowContext[ActionComplete],
|
|
369
|
+
) -> None:
|
|
370
|
+
"""End the workflow by not sending any continuation message."""
|
|
371
|
+
# Don't send ActionComplete - workflow ends here
|
|
372
|
+
pass
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class EndConversationExecutor(DeclarativeActionExecutor):
|
|
376
|
+
"""Executor for EndConversation action."""
|
|
377
|
+
|
|
378
|
+
@handler
|
|
379
|
+
async def handle_action(
|
|
380
|
+
self,
|
|
381
|
+
trigger: Any,
|
|
382
|
+
ctx: WorkflowContext[ActionComplete],
|
|
383
|
+
) -> None:
|
|
384
|
+
"""End the conversation."""
|
|
385
|
+
# For now, just don't continue
|
|
386
|
+
# In a full implementation, this would signal to close the conversation
|
|
387
|
+
pass
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# Passthrough executor for joining control flow branches
|
|
391
|
+
class JoinExecutor(DeclarativeActionExecutor):
|
|
392
|
+
"""Executor that joins multiple branches back together.
|
|
393
|
+
|
|
394
|
+
Used after If/ConditionGroup to merge control flow back to a single path.
|
|
395
|
+
Also used as passthrough nodes for else/default branches.
|
|
396
|
+
"""
|
|
397
|
+
|
|
398
|
+
@handler
|
|
399
|
+
async def handle_action(
|
|
400
|
+
self,
|
|
401
|
+
trigger: dict[str, Any]
|
|
402
|
+
| str
|
|
403
|
+
| list[Message]
|
|
404
|
+
| ActionTrigger
|
|
405
|
+
| ActionComplete
|
|
406
|
+
| ConditionResult
|
|
407
|
+
| LoopIterationResult,
|
|
408
|
+
ctx: WorkflowContext[ActionComplete],
|
|
409
|
+
) -> None:
|
|
410
|
+
"""Simply pass through to continue the workflow."""
|
|
411
|
+
await self._ensure_state_initialized(ctx, trigger)
|
|
412
|
+
await ctx.send_message(ActionComplete())
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class CancelDialogExecutor(DeclarativeActionExecutor):
|
|
416
|
+
"""Executor for CancelDialog action.
|
|
417
|
+
|
|
418
|
+
Cancels the current dialog/workflow, equivalent to .NET CancelDialog.
|
|
419
|
+
This terminates execution similarly to EndWorkflow.
|
|
420
|
+
"""
|
|
421
|
+
|
|
422
|
+
@handler
|
|
423
|
+
async def handle_action(
|
|
424
|
+
self,
|
|
425
|
+
trigger: Any,
|
|
426
|
+
ctx: WorkflowContext[ActionComplete],
|
|
427
|
+
) -> None:
|
|
428
|
+
"""Cancel the current dialog/workflow."""
|
|
429
|
+
# CancelDialog terminates execution without continuing
|
|
430
|
+
# Similar to EndWorkflow but semantically different (cancellation vs completion)
|
|
431
|
+
pass
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class CancelAllDialogsExecutor(DeclarativeActionExecutor):
|
|
435
|
+
"""Executor for CancelAllDialogs action.
|
|
436
|
+
|
|
437
|
+
Cancels all dialogs in the execution stack, equivalent to .NET CancelAllDialogs.
|
|
438
|
+
This terminates the entire workflow execution.
|
|
439
|
+
"""
|
|
440
|
+
|
|
441
|
+
@handler
|
|
442
|
+
async def handle_action(
|
|
443
|
+
self,
|
|
444
|
+
trigger: Any,
|
|
445
|
+
ctx: WorkflowContext[ActionComplete],
|
|
446
|
+
) -> None:
|
|
447
|
+
"""Cancel all dialogs/workflows."""
|
|
448
|
+
# CancelAllDialogs terminates all execution
|
|
449
|
+
pass
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# Mapping of control flow action kinds to executor classes
|
|
453
|
+
# Note: Most control flow is handled by the builder creating graph structure,
|
|
454
|
+
# these are the executors that are part of that structure
|
|
455
|
+
CONTROL_FLOW_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
|
|
456
|
+
"EndWorkflow": EndWorkflowExecutor,
|
|
457
|
+
"EndDialog": EndWorkflowExecutor,
|
|
458
|
+
"EndConversation": EndConversationExecutor,
|
|
459
|
+
"CancelDialog": CancelDialogExecutor,
|
|
460
|
+
"CancelAllDialogs": CancelAllDialogsExecutor,
|
|
461
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""External input executors for declarative workflows.
|
|
4
|
+
|
|
5
|
+
These executors handle interactions that require external input (user questions
|
|
6
|
+
and external integrations), using the request_info pattern to pause the workflow
|
|
7
|
+
and wait for responses.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, cast
|
|
13
|
+
|
|
14
|
+
from agent_framework import (
|
|
15
|
+
WorkflowContext,
|
|
16
|
+
handler,
|
|
17
|
+
response_handler,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from ._declarative_base import (
|
|
21
|
+
ActionComplete,
|
|
22
|
+
DeclarativeActionExecutor,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_prompt_text(action_def: dict[str, Any], primary_key: str, fallback_key: str) -> Any:
|
|
27
|
+
"""Return the prompt text from an action definition.
|
|
28
|
+
|
|
29
|
+
Accepts a nested ``{primary_key: {"text": ...}}`` mapping, a bare
|
|
30
|
+
string under ``primary_key``, or a top-level ``fallback_key`` value.
|
|
31
|
+
"""
|
|
32
|
+
match action_def.get(primary_key):
|
|
33
|
+
case {"text": text}:
|
|
34
|
+
return text
|
|
35
|
+
case str() as text:
|
|
36
|
+
return text
|
|
37
|
+
case _:
|
|
38
|
+
return action_def.get(fallback_key, "")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_output_path(action_def: dict[str, Any], default: str) -> str:
|
|
42
|
+
"""Return the state path where the action result should be written.
|
|
43
|
+
|
|
44
|
+
Looks at ``variable``, then ``output.property``, then top-level
|
|
45
|
+
``property``, falling back to ``default``.
|
|
46
|
+
"""
|
|
47
|
+
output = action_def.get("output")
|
|
48
|
+
nested = cast(dict[str, Any], output).get("property") if isinstance(output, dict) else None
|
|
49
|
+
return action_def.get("variable") or nested or action_def.get("property") or default
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ExternalInputRequest:
|
|
54
|
+
"""Request for external input (triggers workflow pause).
|
|
55
|
+
|
|
56
|
+
Aligns with .NET ExternalInputRequest pattern. Used by Question and
|
|
57
|
+
RequestExternalInput executors to signal that user input is needed.
|
|
58
|
+
The workflow will pause via request_info and wait for an ExternalInputResponse.
|
|
59
|
+
|
|
60
|
+
Attributes:
|
|
61
|
+
request_id: Unique identifier for this request.
|
|
62
|
+
message: The prompt or question to display to the user.
|
|
63
|
+
request_type: A free-form discriminator describing the kind of input
|
|
64
|
+
being requested. ``QuestionExecutor`` emits ``"question"`` and
|
|
65
|
+
``RequestExternalInputExecutor`` defaults to ``"external"``; callers
|
|
66
|
+
may supply any other string via the ``requestType`` field on a
|
|
67
|
+
``RequestExternalInput`` action (e.g. ``"approval"``) and it is
|
|
68
|
+
propagated unchanged.
|
|
69
|
+
metadata: Additional context (choices, output_property, timeout, etc.).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
request_id: str
|
|
73
|
+
message: str
|
|
74
|
+
request_type: str = "external"
|
|
75
|
+
metadata: dict[str, Any] = field(default_factory=dict) # type: ignore
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class ExternalInputResponse:
|
|
80
|
+
"""Response to an ExternalInputRequest.
|
|
81
|
+
|
|
82
|
+
Provided by the caller to resume workflow execution with user input.
|
|
83
|
+
|
|
84
|
+
Attributes:
|
|
85
|
+
user_input: The user's text response.
|
|
86
|
+
value: Optional typed value (e.g., bool for confirmations, selected choice).
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
user_input: str
|
|
90
|
+
value: Any = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class QuestionExecutor(DeclarativeActionExecutor):
|
|
94
|
+
"""Executor that asks the user a question and waits for a response.
|
|
95
|
+
|
|
96
|
+
Uses the request_info pattern to pause execution until the user provides an answer.
|
|
97
|
+
The response is stored in workflow state at the configured output property.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
@handler
|
|
101
|
+
async def handle_action(
|
|
102
|
+
self,
|
|
103
|
+
trigger: Any,
|
|
104
|
+
ctx: WorkflowContext[ActionComplete],
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Ask the question and wait for a response."""
|
|
107
|
+
state = await self._ensure_state_initialized(ctx, trigger)
|
|
108
|
+
|
|
109
|
+
question_text = _get_prompt_text(self._action_def, primary_key="question", fallback_key="text")
|
|
110
|
+
output_property = _get_output_path(self._action_def, default="Local.answer")
|
|
111
|
+
default_value = self._action_def.get("default", self._action_def.get("defaultValue"))
|
|
112
|
+
choices = self._action_def.get("choices", [])
|
|
113
|
+
allow_free_text = self._action_def.get("allowFreeText", True)
|
|
114
|
+
|
|
115
|
+
evaluated_question = state.eval_if_expression(question_text)
|
|
116
|
+
|
|
117
|
+
# Build choices metadata
|
|
118
|
+
choices_data: list[dict[str, str]] | None = None
|
|
119
|
+
if choices:
|
|
120
|
+
choices_data = []
|
|
121
|
+
for c in choices:
|
|
122
|
+
if isinstance(c, dict):
|
|
123
|
+
c_dict: dict[str, Any] = dict(c) # type: ignore[arg-type]
|
|
124
|
+
choices_data.append({
|
|
125
|
+
"value": c_dict.get("value", ""),
|
|
126
|
+
"label": c_dict.get("label") or c_dict.get("value", ""),
|
|
127
|
+
})
|
|
128
|
+
else:
|
|
129
|
+
choices_data.append({"value": str(c), "label": str(c)})
|
|
130
|
+
|
|
131
|
+
# Store output property in shared state for response handler
|
|
132
|
+
ctx.state.set("_question_output_property", output_property)
|
|
133
|
+
ctx.state.set("_question_default_value", default_value)
|
|
134
|
+
|
|
135
|
+
# Request external input - workflow pauses here
|
|
136
|
+
await ctx.request_info(
|
|
137
|
+
ExternalInputRequest(
|
|
138
|
+
request_id=str(uuid.uuid4()),
|
|
139
|
+
message=str(evaluated_question),
|
|
140
|
+
request_type="question",
|
|
141
|
+
metadata={
|
|
142
|
+
"output_property": output_property,
|
|
143
|
+
"choices": choices_data,
|
|
144
|
+
"allow_free_text": allow_free_text,
|
|
145
|
+
"default_value": default_value,
|
|
146
|
+
},
|
|
147
|
+
),
|
|
148
|
+
ExternalInputResponse,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
@response_handler
|
|
152
|
+
async def handle_response(
|
|
153
|
+
self,
|
|
154
|
+
original_request: ExternalInputRequest,
|
|
155
|
+
response: ExternalInputResponse,
|
|
156
|
+
ctx: WorkflowContext[ActionComplete],
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Handle the user's response to the question."""
|
|
159
|
+
state = self._get_state(ctx.state)
|
|
160
|
+
|
|
161
|
+
output_property = original_request.metadata.get("output_property", "Local.answer")
|
|
162
|
+
answer = response.value if response.value is not None else response.user_input
|
|
163
|
+
|
|
164
|
+
if output_property:
|
|
165
|
+
state.set(output_property, answer)
|
|
166
|
+
|
|
167
|
+
await ctx.send_message(ActionComplete())
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class RequestExternalInputExecutor(DeclarativeActionExecutor):
|
|
171
|
+
"""Executor that requests external input/approval.
|
|
172
|
+
|
|
173
|
+
Used for complex external integrations beyond simple questions,
|
|
174
|
+
such as approval workflows, document uploads, or external system integrations.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
@handler
|
|
178
|
+
async def handle_action(
|
|
179
|
+
self,
|
|
180
|
+
trigger: Any,
|
|
181
|
+
ctx: WorkflowContext[ActionComplete],
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Request external input."""
|
|
184
|
+
state = await self._ensure_state_initialized(ctx, trigger)
|
|
185
|
+
|
|
186
|
+
message = _get_prompt_text(self._action_def, primary_key="prompt", fallback_key="message")
|
|
187
|
+
output_property = _get_output_path(self._action_def, default="Local.externalInput")
|
|
188
|
+
default_value = self._action_def.get("default")
|
|
189
|
+
|
|
190
|
+
request_type = self._action_def.get("requestType", "external")
|
|
191
|
+
timeout_seconds = self._action_def.get("timeout")
|
|
192
|
+
required_fields = self._action_def.get("requiredFields", [])
|
|
193
|
+
metadata = self._action_def.get("metadata", {})
|
|
194
|
+
|
|
195
|
+
evaluated_message = state.eval_if_expression(message)
|
|
196
|
+
|
|
197
|
+
# Build request metadata
|
|
198
|
+
request_metadata: dict[str, Any] = {
|
|
199
|
+
**metadata,
|
|
200
|
+
"output_property": output_property,
|
|
201
|
+
"required_fields": required_fields,
|
|
202
|
+
"default_value": default_value,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if timeout_seconds:
|
|
206
|
+
request_metadata["timeout_seconds"] = timeout_seconds
|
|
207
|
+
|
|
208
|
+
# Request external input - workflow pauses here
|
|
209
|
+
await ctx.request_info(
|
|
210
|
+
ExternalInputRequest(
|
|
211
|
+
request_id=str(uuid.uuid4()),
|
|
212
|
+
message=str(evaluated_message),
|
|
213
|
+
request_type=request_type,
|
|
214
|
+
metadata=request_metadata,
|
|
215
|
+
),
|
|
216
|
+
ExternalInputResponse,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
@response_handler
|
|
220
|
+
async def handle_response(
|
|
221
|
+
self,
|
|
222
|
+
original_request: ExternalInputRequest,
|
|
223
|
+
response: ExternalInputResponse,
|
|
224
|
+
ctx: WorkflowContext[ActionComplete],
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Handle the external input response."""
|
|
227
|
+
state = self._get_state(ctx.state)
|
|
228
|
+
|
|
229
|
+
output_property = original_request.metadata.get("output_property", "Local.externalInput")
|
|
230
|
+
|
|
231
|
+
# Store the response value or user_input
|
|
232
|
+
result = response.value if response.value is not None else response.user_input
|
|
233
|
+
if output_property:
|
|
234
|
+
state.set(output_property, result)
|
|
235
|
+
|
|
236
|
+
await ctx.send_message(ActionComplete())
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# Mapping of external input action kinds to executor classes
|
|
240
|
+
EXTERNAL_INPUT_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
|
|
241
|
+
"Question": QuestionExecutor,
|
|
242
|
+
"RequestExternalInput": RequestExternalInputExecutor,
|
|
243
|
+
}
|