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,574 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ """Basic action executors for the graph-based declarative workflow system.
4
+
5
+ These executors handle simple actions like SetValue, SendActivity, etc.
6
+ Each action becomes a node in the workflow graph.
7
+ """
8
+
9
+ import uuid
10
+ from collections.abc import Mapping
11
+ from typing import Any, cast
12
+
13
+ from agent_framework import (
14
+ WorkflowContext,
15
+ handler,
16
+ )
17
+
18
+ from ._declarative_base import (
19
+ ActionComplete,
20
+ DeclarativeActionExecutor,
21
+ )
22
+
23
+
24
+ def _get_variable_path(action_def: dict[str, Any], key: str = "variable") -> str | None:
25
+ """Extract variable path from action definition.
26
+
27
+ Supports .NET style (variable: Local.VarName) and nested object style (variable: {path: ...}).
28
+ """
29
+ variable = action_def.get(key)
30
+ if isinstance(variable, str):
31
+ return variable
32
+ if isinstance(variable, Mapping):
33
+ path = variable.get("path") # type: ignore[reportUnknownVariableType]
34
+ return path if isinstance(path, str) else None
35
+
36
+ fallback_path = action_def.get("path")
37
+ return fallback_path if isinstance(fallback_path, str) else None
38
+
39
+
40
+ class SetValueExecutor(DeclarativeActionExecutor):
41
+ """Executor for the SetValue action.
42
+
43
+ Sets a value in the workflow state at a specified path.
44
+ """
45
+
46
+ @handler
47
+ async def handle_action(
48
+ self,
49
+ trigger: Any,
50
+ ctx: WorkflowContext[ActionComplete],
51
+ ) -> None:
52
+ """Handle the SetValue action."""
53
+ state = await self._ensure_state_initialized(ctx, trigger)
54
+
55
+ path = self._action_def.get("path")
56
+ value = self._action_def.get("value")
57
+
58
+ if path:
59
+ # Evaluate value if it's an expression
60
+ evaluated_value = state.eval_if_expression(value)
61
+ state.set(path, evaluated_value)
62
+
63
+ await ctx.send_message(ActionComplete())
64
+
65
+
66
+ class SetVariableExecutor(DeclarativeActionExecutor):
67
+ """Executor for the SetVariable action (.NET style naming)."""
68
+
69
+ @handler
70
+ async def handle_action(
71
+ self,
72
+ trigger: Any,
73
+ ctx: WorkflowContext[ActionComplete],
74
+ ) -> None:
75
+ """Handle the SetVariable action."""
76
+ state = await self._ensure_state_initialized(ctx, trigger)
77
+
78
+ path = _get_variable_path(self._action_def)
79
+ value = self._action_def.get("value")
80
+
81
+ if path:
82
+ evaluated_value = state.eval_if_expression(value)
83
+ state.set(path, evaluated_value)
84
+
85
+ await ctx.send_message(ActionComplete())
86
+
87
+
88
+ class CreateConversationExecutor(DeclarativeActionExecutor):
89
+ """Executor for the CreateConversation action.
90
+
91
+ Generates a unique conversation ID and initialises a conversation entry
92
+ in ``System.conversations``. The generated ID is stored at the state
93
+ path specified by the ``conversationId`` parameter (if provided).
94
+ """
95
+
96
+ @handler
97
+ async def handle_action(
98
+ self,
99
+ trigger: Any,
100
+ ctx: WorkflowContext[ActionComplete],
101
+ ) -> None:
102
+ """Handle the CreateConversation action."""
103
+ state = await self._ensure_state_initialized(ctx, trigger)
104
+
105
+ generated_id = str(uuid.uuid4())
106
+
107
+ # Store the generated ID at the requested path (e.g. "Local.myConvId")
108
+ conversation_id_path = _get_variable_path(self._action_def, "conversationId")
109
+ if conversation_id_path:
110
+ state.set(conversation_id_path, generated_id)
111
+
112
+ # Initialise the conversation entry in System.conversations
113
+ conversations: dict[str, Any] = state.get("System.conversations") or {}
114
+ conversations[generated_id] = {
115
+ "id": generated_id,
116
+ "messages": [],
117
+ }
118
+ state.set("System.conversations", conversations)
119
+
120
+ await ctx.send_message(ActionComplete())
121
+
122
+
123
+ class SetTextVariableExecutor(DeclarativeActionExecutor):
124
+ """Executor for the SetTextVariable action."""
125
+
126
+ @handler
127
+ async def handle_action(
128
+ self,
129
+ trigger: Any,
130
+ ctx: WorkflowContext[ActionComplete],
131
+ ) -> None:
132
+ """Handle the SetTextVariable action."""
133
+ state = await self._ensure_state_initialized(ctx, trigger)
134
+
135
+ path = _get_variable_path(self._action_def)
136
+ text = self._action_def.get("text", "")
137
+
138
+ if path:
139
+ evaluated_text = state.eval_if_expression(text)
140
+ state.set(path, str(evaluated_text) if evaluated_text is not None else "")
141
+
142
+ await ctx.send_message(ActionComplete())
143
+
144
+
145
+ class SetMultipleVariablesExecutor(DeclarativeActionExecutor):
146
+ """Executor for the SetMultipleVariables action."""
147
+
148
+ @handler
149
+ async def handle_action(
150
+ self,
151
+ trigger: Any,
152
+ ctx: WorkflowContext[ActionComplete],
153
+ ) -> None:
154
+ """Handle the SetMultipleVariables action."""
155
+ state = await self._ensure_state_initialized(ctx, trigger)
156
+
157
+ assignments = cast(
158
+ list[Mapping[str, Any]],
159
+ self._action_def.get("assignments") if isinstance(self._action_def.get("assignments"), list) else [],
160
+ )
161
+ for assignment in assignments:
162
+ if not isinstance(assignment, Mapping):
163
+ continue
164
+ variable = assignment.get("variable")
165
+ path: str | None
166
+ if isinstance(variable, str):
167
+ path = variable
168
+ elif isinstance(variable, Mapping):
169
+ path_value = variable.get("path") # type: ignore[reportUnknownMemberType]
170
+ path = path_value if isinstance(path_value, str) else None
171
+ else:
172
+ fallback_path = assignment.get("path")
173
+ path = fallback_path if isinstance(fallback_path, str) else None
174
+ value = assignment.get("value")
175
+ if path:
176
+ evaluated_value = state.eval_if_expression(value)
177
+ state.set(path, evaluated_value)
178
+
179
+ await ctx.send_message(ActionComplete())
180
+
181
+
182
+ class ResetVariableExecutor(DeclarativeActionExecutor):
183
+ """Executor for the ResetVariable action."""
184
+
185
+ @handler
186
+ async def handle_action(
187
+ self,
188
+ trigger: Any,
189
+ ctx: WorkflowContext[ActionComplete],
190
+ ) -> None:
191
+ """Handle the ResetVariable action."""
192
+ state = await self._ensure_state_initialized(ctx, trigger)
193
+
194
+ path = _get_variable_path(self._action_def)
195
+
196
+ if path:
197
+ # Reset to None/empty
198
+ state.set(path, None)
199
+
200
+ await ctx.send_message(ActionComplete())
201
+
202
+
203
+ class ClearAllVariablesExecutor(DeclarativeActionExecutor):
204
+ """Executor for the ClearAllVariables action."""
205
+
206
+ @handler
207
+ async def handle_action(
208
+ self,
209
+ trigger: Any,
210
+ ctx: WorkflowContext[ActionComplete],
211
+ ) -> None:
212
+ """Handle the ClearAllVariables action."""
213
+ state = await self._ensure_state_initialized(ctx, trigger)
214
+
215
+ # Get state data and clear Local variables
216
+ state_data = state.get_state_data()
217
+ state_data["Local"] = {}
218
+ state.set_state_data(state_data)
219
+
220
+ await ctx.send_message(ActionComplete())
221
+
222
+
223
+ class SendActivityExecutor(DeclarativeActionExecutor):
224
+ """Executor for the SendActivity action.
225
+
226
+ Sends a text message or activity as workflow output.
227
+ """
228
+
229
+ @handler
230
+ async def handle_action(
231
+ self,
232
+ trigger: Any,
233
+ ctx: WorkflowContext[ActionComplete, str],
234
+ ) -> None:
235
+ """Handle the SendActivity action."""
236
+ state = await self._ensure_state_initialized(ctx, trigger)
237
+
238
+ activity = self._action_def.get("activity", "")
239
+
240
+ # Activity can be a string directly or a dict with a "text" field
241
+ if isinstance(activity, Mapping):
242
+ text: Any = activity.get("text", "") # type: ignore[reportUnknownMemberType]
243
+ else:
244
+ text = activity
245
+
246
+ if isinstance(text, str):
247
+ # First evaluate any =expression syntax
248
+ text = state.eval_if_expression(text)
249
+ # Then interpolate any {Variable.Path} template syntax
250
+ if isinstance(text, str):
251
+ text = state.interpolate_string(text)
252
+
253
+ # Yield the text as workflow output
254
+ if text:
255
+ await ctx.yield_output(str(text)) # type: ignore[reportUnknownArgumentType]
256
+
257
+ await ctx.send_message(ActionComplete())
258
+
259
+
260
+ class EditTableExecutor(DeclarativeActionExecutor):
261
+ """Executor for the EditTable action.
262
+
263
+ Performs operations on a table (list) variable such as add, remove, or clear.
264
+ This is equivalent to the .NET EditTable action.
265
+
266
+ YAML example:
267
+ - kind: EditTable
268
+ table: Local.Items
269
+ operation: add # add, remove, clear
270
+ value: =Local.NewItem
271
+ index: 0 # optional, for insert at position
272
+ """
273
+
274
+ @handler
275
+ async def handle_action(
276
+ self,
277
+ trigger: Any,
278
+ ctx: WorkflowContext[ActionComplete],
279
+ ) -> None:
280
+ """Handle the EditTable action."""
281
+ state = await self._ensure_state_initialized(ctx, trigger)
282
+
283
+ table_path = self._action_def.get("table") or _get_variable_path(self._action_def, "variable")
284
+ operation = self._action_def.get("operation", "add").lower()
285
+ value = self._action_def.get("value")
286
+ index = self._action_def.get("index")
287
+
288
+ if table_path:
289
+ # Get current table value
290
+ current_table_value = state.get(table_path)
291
+ current_table: list[Any]
292
+ if current_table_value is None:
293
+ current_table = []
294
+ elif isinstance(current_table_value, list):
295
+ current_table = list(current_table_value) # type: ignore[reportUnknownArgumentType]
296
+ else:
297
+ current_table = [current_table_value]
298
+
299
+ if operation == "add" or operation == "insert":
300
+ evaluated_value = state.eval_if_expression(value)
301
+ if index is not None:
302
+ evaluated_index = state.eval_if_expression(index)
303
+ idx = int(evaluated_index) if evaluated_index is not None else len(current_table)
304
+ current_table.insert(idx, evaluated_value)
305
+ else:
306
+ current_table.append(evaluated_value)
307
+
308
+ elif operation == "remove":
309
+ if value is not None:
310
+ # Remove by value
311
+ evaluated_value = state.eval_if_expression(value)
312
+ if evaluated_value in current_table:
313
+ current_table.remove(evaluated_value)
314
+ elif index is not None:
315
+ # Remove by index
316
+ evaluated_index = state.eval_if_expression(index)
317
+ idx = int(evaluated_index) if evaluated_index is not None else -1
318
+ if 0 <= idx < len(current_table):
319
+ current_table.pop(idx)
320
+
321
+ elif operation == "clear":
322
+ current_table = []
323
+
324
+ elif operation == "set" or operation == "update":
325
+ # Update item at index
326
+ if index is not None:
327
+ evaluated_value = state.eval_if_expression(value)
328
+ evaluated_index = state.eval_if_expression(index)
329
+ idx = int(evaluated_index) if evaluated_index is not None else 0
330
+ if 0 <= idx < len(current_table):
331
+ current_table[idx] = evaluated_value
332
+
333
+ state.set(table_path, current_table)
334
+
335
+ await ctx.send_message(ActionComplete())
336
+
337
+
338
+ class EditTableV2Executor(DeclarativeActionExecutor):
339
+ """Executor for the EditTableV2 action.
340
+
341
+ Enhanced table editing with more operations and better record support.
342
+ This is equivalent to the .NET EditTableV2 action.
343
+
344
+ YAML example:
345
+ - kind: EditTableV2
346
+ table: Local.Records
347
+ operation: addOrUpdate # add, remove, clear, addOrUpdate, filter
348
+ item: =Local.NewRecord
349
+ key: id # for addOrUpdate, the field to match on
350
+ condition: =item.status = "active" # for filter operation
351
+ """
352
+
353
+ @handler
354
+ async def handle_action(
355
+ self,
356
+ trigger: Any,
357
+ ctx: WorkflowContext[ActionComplete],
358
+ ) -> None:
359
+ """Handle the EditTableV2 action."""
360
+ state = await self._ensure_state_initialized(ctx, trigger)
361
+
362
+ table_path = self._action_def.get("table") or _get_variable_path(self._action_def, "variable")
363
+ operation = self._action_def.get("operation", "add").lower()
364
+ item = self._action_def.get("item") or self._action_def.get("value")
365
+ key_field = self._action_def.get("key")
366
+ index = self._action_def.get("index")
367
+
368
+ if table_path:
369
+ # Get current table value
370
+ current_table_value = state.get(table_path)
371
+ current_table: list[Any]
372
+ if current_table_value is None:
373
+ current_table = []
374
+ elif isinstance(current_table_value, list):
375
+ current_table = list(current_table_value) # type: ignore[reportUnknownArgumentType]
376
+ else:
377
+ current_table = [current_table_value]
378
+
379
+ if operation == "add":
380
+ evaluated_item = state.eval_if_expression(item)
381
+ if index is not None:
382
+ evaluated_index = state.eval_if_expression(index)
383
+ idx = int(evaluated_index) if evaluated_index is not None else len(current_table)
384
+ current_table.insert(idx, evaluated_item)
385
+ else:
386
+ current_table.append(evaluated_item)
387
+
388
+ elif operation == "remove":
389
+ if item is not None:
390
+ evaluated_item = state.eval_if_expression(item)
391
+ if key_field and isinstance(evaluated_item, dict):
392
+ # Remove by key match
393
+ evaluated_item_dict = cast(dict[str, Any], evaluated_item)
394
+ key_value = evaluated_item_dict.get(key_field)
395
+ current_table = [
396
+ r
397
+ for r in current_table
398
+ if not (isinstance(r, dict) and cast(dict[str, Any], r).get(key_field) == key_value)
399
+ ]
400
+ elif evaluated_item in current_table:
401
+ current_table.remove(evaluated_item)
402
+ elif index is not None:
403
+ evaluated_index = state.eval_if_expression(index)
404
+ idx = int(evaluated_index) if evaluated_index is not None else -1
405
+ if 0 <= idx < len(current_table):
406
+ current_table.pop(idx)
407
+
408
+ elif operation == "clear":
409
+ current_table = []
410
+
411
+ elif operation == "addorupdate":
412
+ evaluated_item = state.eval_if_expression(item)
413
+ if key_field and isinstance(evaluated_item, dict):
414
+ key_value = evaluated_item.get(key_field) # type: ignore[reportUnknownArgumentType]
415
+ # Find existing item with same key
416
+ found_idx = -1
417
+ for i, r in enumerate(current_table):
418
+ if isinstance(r, dict) and cast(dict[str, Any], r).get(key_field) == key_value:
419
+ found_idx = i
420
+ break
421
+ if found_idx >= 0:
422
+ # Update existing
423
+ current_table[found_idx] = evaluated_item
424
+ else:
425
+ # Add new
426
+ current_table.append(evaluated_item)
427
+ else:
428
+ # No key field - just add
429
+ current_table.append(evaluated_item)
430
+
431
+ elif operation == "update":
432
+ evaluated_item = state.eval_if_expression(item)
433
+ if index is not None:
434
+ evaluated_index = state.eval_if_expression(index)
435
+ idx = int(evaluated_index) if evaluated_index is not None else 0
436
+ if 0 <= idx < len(current_table):
437
+ current_table[idx] = evaluated_item
438
+ elif key_field and isinstance(evaluated_item, dict):
439
+ key_value = evaluated_item.get(key_field) # type: ignore[reportUnknownArgumentType]
440
+ for i, r in enumerate(current_table):
441
+ if isinstance(r, dict) and cast(dict[str, Any], r).get(key_field) == key_value:
442
+ current_table[i] = evaluated_item
443
+ break
444
+
445
+ state.set(table_path, current_table)
446
+
447
+ await ctx.send_message(ActionComplete())
448
+
449
+
450
+ class ParseValueExecutor(DeclarativeActionExecutor):
451
+ """Executor for the ParseValue action.
452
+
453
+ Parses a value expression and optionally converts it to a target type.
454
+ This is equivalent to the .NET ParseValue action.
455
+
456
+ YAML example:
457
+ - kind: ParseValue
458
+ variable: Local.ParsedData
459
+ value: =System.LastMessage.Text
460
+ valueType: object # optional: string, number, boolean, object, array
461
+ """
462
+
463
+ @handler
464
+ async def handle_action(
465
+ self,
466
+ trigger: Any,
467
+ ctx: WorkflowContext[ActionComplete],
468
+ ) -> None:
469
+ """Handle the ParseValue action."""
470
+ state = await self._ensure_state_initialized(ctx, trigger)
471
+
472
+ path = _get_variable_path(self._action_def)
473
+ value = self._action_def.get("value")
474
+ value_type = self._action_def.get("valueType")
475
+
476
+ if path and value is not None:
477
+ # Evaluate the value expression
478
+ evaluated_value = state.eval_if_expression(value)
479
+
480
+ # Convert to target type if specified
481
+ if value_type:
482
+ evaluated_value = self._convert_to_type(evaluated_value, value_type)
483
+
484
+ state.set(path, evaluated_value)
485
+
486
+ await ctx.send_message(ActionComplete())
487
+
488
+ def _convert_to_type(self, value: Any, target_type: str) -> Any:
489
+ """Convert a value to the specified target type.
490
+
491
+ Args:
492
+ value: The value to convert
493
+ target_type: Target type (string, number, boolean, object, array)
494
+
495
+ Returns:
496
+ The converted value
497
+ """
498
+ import json
499
+
500
+ target_type = target_type.lower()
501
+
502
+ if target_type == "string":
503
+ if value is None:
504
+ return ""
505
+ return str(value)
506
+
507
+ if target_type in ("number", "int", "integer", "float", "decimal"):
508
+ if value is None:
509
+ return 0
510
+ if isinstance(value, str):
511
+ # Try to parse as number
512
+ try:
513
+ if "." in value:
514
+ return float(value)
515
+ return int(value)
516
+ except ValueError:
517
+ return 0
518
+ return float(value) if isinstance(value, (int, float)) else 0
519
+
520
+ if target_type in ("boolean", "bool"):
521
+ if value is None:
522
+ return False
523
+ if isinstance(value, str):
524
+ return value.lower() in ("true", "yes", "1", "on")
525
+ return bool(value)
526
+
527
+ if target_type in ("object", "record"):
528
+ if value is None:
529
+ return {}
530
+ if isinstance(value, dict):
531
+ return cast(dict[str, Any], value)
532
+ if isinstance(value, str):
533
+ try:
534
+ parsed = json.loads(value)
535
+ if isinstance(parsed, dict):
536
+ return cast(dict[str, Any], parsed)
537
+ return {"value": parsed}
538
+ except json.JSONDecodeError:
539
+ return {"value": value}
540
+ return {"value": value}
541
+
542
+ if target_type in ("array", "table", "list"):
543
+ if value is None:
544
+ return []
545
+ if isinstance(value, list):
546
+ return cast(list[Any], value)
547
+ if isinstance(value, str):
548
+ try:
549
+ parsed = json.loads(value)
550
+ if isinstance(parsed, list):
551
+ return cast(list[Any], parsed)
552
+ return [parsed]
553
+ except json.JSONDecodeError:
554
+ return [value]
555
+ return [value]
556
+
557
+ # Unknown type - return as-is
558
+ return value
559
+
560
+
561
+ # Mapping of action kinds to executor classes
562
+ BASIC_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
563
+ "CreateConversation": CreateConversationExecutor,
564
+ "SetValue": SetValueExecutor,
565
+ "SetVariable": SetVariableExecutor,
566
+ "SetTextVariable": SetTextVariableExecutor,
567
+ "SetMultipleVariables": SetMultipleVariablesExecutor,
568
+ "ResetVariable": ResetVariableExecutor,
569
+ "ClearAllVariables": ClearAllVariablesExecutor,
570
+ "SendActivity": SendActivityExecutor,
571
+ "ParseValue": ParseValueExecutor,
572
+ "EditTable": EditTableExecutor,
573
+ "EditTableV2": EditTableV2Executor,
574
+ }