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,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
+ }