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,1226 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ """Base classes for graph-based declarative workflow executors.
4
+
5
+ This module provides:
6
+ - DeclarativeWorkflowState: Manages workflow variables via State
7
+ - DeclarativeActionExecutor: Base class for action executors
8
+ - Message types for inter-executor communication
9
+
10
+ PowerFx Expression Evaluation
11
+ -----------------------------
12
+ The .NET version uses RecalcEngine with:
13
+ 1. Pre-registered custom functions (UserMessage, AgentMessage, MessageText)
14
+ 2. Typed schemas for variables defined at compile time
15
+ 3. UpdateVariable() to register mutable state with proper types
16
+
17
+ The Python `powerfx` library only exposes eval() with runtime symbols, not
18
+ the full RecalcEngine API. We work around this by:
19
+ 1. Pre-processing custom functions (UserMessage, MessageText) before PowerFx
20
+ 2. Gracefully handling undefined variable errors (returning None)
21
+ 3. Converting non-serializable objects to PowerFx-safe types at runtime
22
+
23
+ See: dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import locale
29
+ import logging
30
+ import os
31
+ import re
32
+ import sys
33
+ import uuid
34
+ from collections.abc import Mapping
35
+ from dataclasses import dataclass, field
36
+ from decimal import Decimal as _Decimal
37
+ from enum import Enum
38
+ from types import MappingProxyType
39
+ from typing import Any, Literal, cast
40
+
41
+ from agent_framework import (
42
+ Executor,
43
+ Message,
44
+ WorkflowContext,
45
+ )
46
+ from agent_framework._workflows._state import State
47
+
48
+ try:
49
+ from powerfx import Engine
50
+ except (ImportError, RuntimeError):
51
+ # ImportError: powerfx package not installed
52
+ # RuntimeError: .NET runtime not available or misconfigured
53
+ Engine = None
54
+
55
+ if sys.version_info >= (3, 11):
56
+ from typing import TypedDict # pragma: no cover
57
+ else:
58
+ from typing_extensions import TypedDict # pragma: no cover
59
+
60
+
61
+ logger = logging.getLogger(__name__)
62
+
63
+
64
+ _ENV_REFERENCE_RE = re.compile(r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)")
65
+
66
+ # Allowed identifier shape for object-attribute steps in declarative state paths
67
+ _SAFE_PATH_SEGMENT_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$")
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class DeclarativeEnvConfig:
72
+ """Configuration that populates the PowerFx ``Env`` symbol for a workflow.
73
+
74
+ Configuration values are always exposed under ``Env.<name>``;
75
+ ``os.environ`` is consulted only when ``restrict_to_configuration``
76
+ is ``False`` AND the YAML literally references the name in a PowerFx
77
+ expression (the allowlist enforced via ``referenced_names``).
78
+
79
+ Attributes:
80
+ values: Caller-supplied configuration resolved by name when the
81
+ workflow YAML references ``=Env.NAME``. Always exposed in
82
+ the ``Env`` symbol regardless of ``restrict_to_configuration``.
83
+ restrict_to_configuration: When ``True`` (default), the ``Env``
84
+ symbol is populated exclusively from ``values``; ``os.environ``
85
+ is never consulted. Set to ``False`` to additionally fall back
86
+ to ``os.environ`` for names absent from ``values`` that the
87
+ workflow YAML explicitly references.
88
+ referenced_names: The set of ``Env.NAME`` symbols discovered in
89
+ PowerFx expressions inside the workflow definition. The
90
+ ``os.environ`` fallback is constrained to this allowlist so
91
+ unrelated environment variables never enter the PowerFx scope.
92
+ """
93
+
94
+ values: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({}))
95
+ restrict_to_configuration: bool = True
96
+ referenced_names: frozenset[str] = field(default_factory=lambda: frozenset[str]())
97
+
98
+ def __post_init__(self) -> None:
99
+ # Defensive snapshots so the frozen guarantee extends to the
100
+ # contents of ``values`` / ``referenced_names``: caller mutations
101
+ # to the original objects after construction cannot leak into
102
+ # ``resolve()``.
103
+ object.__setattr__(self, "values", MappingProxyType(dict(self.values)))
104
+ object.__setattr__(self, "referenced_names", frozenset(self.referenced_names))
105
+
106
+ def resolve(self) -> dict[str, str]:
107
+ """Return the resolved ``Env`` symbol mapping for the workflow.
108
+
109
+ Configuration values are always included (stringified).
110
+ ``os.environ`` is consulted only when ``restrict_to_configuration``
111
+ is ``False`` and the name appears in ``referenced_names``, so
112
+ unrelated environment variables never enter the PowerFx scope.
113
+ Configuration values always win over the environment fallback.
114
+ """
115
+ resolved = {name: str(value) for name, value in self.values.items()}
116
+ if self.restrict_to_configuration:
117
+ return resolved
118
+ for name in self.referenced_names.difference(resolved):
119
+ env_value = os.environ.get(name)
120
+ if env_value is not None:
121
+ resolved[name] = env_value
122
+ return resolved
123
+
124
+
125
+ def discover_env_references(node: Any) -> set[str]:
126
+ """Discover ``Env.NAME`` references in PowerFx expressions inside ``node``.
127
+
128
+ Walks any nested ``Mapping``/``list``/scalar structure and inspects every
129
+ string value. To avoid false positives from doc/description fields that
130
+ happen to mention ``Env.SOMETHING`` as plain text, the scan only inspects
131
+ strings that begin with ``=`` (PowerFx expression marker, matching the
132
+ convention enforced by :meth:`DeclarativeWorkflowState.eval`).
133
+
134
+ Args:
135
+ node: A parsed workflow definition (typically the dict produced by
136
+ ``yaml.safe_load``).
137
+
138
+ Returns:
139
+ The set of ``Env`` identifier names referenced in PowerFx
140
+ expressions inside ``node``.
141
+ """
142
+ names: set[str] = set()
143
+
144
+ def visit(value: Any) -> None:
145
+ if isinstance(value, str):
146
+ if value.startswith("="):
147
+ names.update(_ENV_REFERENCE_RE.findall(value))
148
+ return
149
+ if isinstance(value, Mapping):
150
+ for inner in cast(Mapping[Any, Any], value).values():
151
+ visit(inner)
152
+ return
153
+ if isinstance(value, list):
154
+ for item in cast(list[Any], value):
155
+ visit(item)
156
+
157
+ visit(node)
158
+ return names
159
+
160
+
161
+ class ConversationData(TypedDict):
162
+ """Structure for conversation-related state data.
163
+
164
+ Attributes:
165
+ messages: Active conversation messages for the current agent interaction.
166
+ This is the primary storage used by InvokeAgent actions.
167
+ history: Deprecated. Previously used as a separate history buffer, but
168
+ messages and history are now kept in sync. Use messages instead.
169
+ """
170
+
171
+ messages: list[Any]
172
+ history: list[Any] # Deprecated: use messages instead
173
+
174
+
175
+ class DeclarativeStateData(TypedDict, total=False):
176
+ """Structure for the declarative workflow state stored in State.
177
+
178
+ This TypedDict defines the schema for workflow variables stored
179
+ under the DECLARATIVE_STATE_KEY in State.
180
+
181
+ Variable Scopes (matching .NET naming conventions):
182
+ Inputs: Initial workflow inputs (read-only after initialization).
183
+ Outputs: Values to return from the workflow.
184
+ Local: Variables persisting within the current workflow turn.
185
+ System: System-level variables (ConversationId, LastMessage, etc.).
186
+ Agent: Results from the most recent agent invocation.
187
+ Conversation: Conversation history and messages.
188
+ Custom: User-defined custom variables.
189
+ _declarative_loop_state: Internal loop iteration state (managed by ForeachExecutors).
190
+ """
191
+
192
+ Inputs: dict[str, Any]
193
+ Outputs: dict[str, Any]
194
+ Local: dict[str, Any]
195
+ System: dict[str, Any]
196
+ Agent: dict[str, Any]
197
+ Conversation: ConversationData
198
+ Custom: dict[str, Any]
199
+ _declarative_loop_state: dict[str, Any]
200
+
201
+
202
+ # Key used in State to store declarative workflow variables
203
+ DECLARATIVE_STATE_KEY = "_declarative_workflow_state"
204
+
205
+
206
+ # Types that PowerFx can serialize directly
207
+ # Note: Decimal is included because PowerFx returns Decimal for numeric values
208
+ _POWERFX_SAFE_TYPES = (str, int, float, bool, type(None), _Decimal)
209
+ _POWERFX_EVAL_LOCALE = "en-US"
210
+ _POWERFX_NUMERIC_LOCALE_CANDIDATES = ("en_US.UTF-8", "en_US", "C")
211
+
212
+
213
+ def _make_powerfx_safe(value: Any) -> Any:
214
+ """Convert a value to a PowerFx-serializable form.
215
+
216
+ PowerFx can only serialize primitive types, dicts, and lists.
217
+ Custom objects (like Message) must be converted to dicts or excluded.
218
+
219
+ Args:
220
+ value: Any Python value
221
+
222
+ Returns:
223
+ A PowerFx-safe representation of the value
224
+ """
225
+ if value is None:
226
+ return value
227
+
228
+ # Enum coercion must run BEFORE the primitive type check: many MAF
229
+ # enums (e.g. MessageRole) are ``str``-subclass enums, so they pass
230
+ # ``isinstance(v, str)`` but pythonnet refuses to convert them to
231
+ # ``System.String`` and raises ``'MessageRole' value cannot be
232
+ # converted to System.<X>'`` for every PowerFx primitive type. Reduce
233
+ # to the underlying value (or its string form) so PowerFx sees a
234
+ # plain ``str``/``int``.
235
+ if isinstance(value, Enum):
236
+ return _make_powerfx_safe(value.value)
237
+
238
+ if isinstance(value, _POWERFX_SAFE_TYPES):
239
+ return value
240
+
241
+ if isinstance(value, dict):
242
+ value_dict = cast(Mapping[Any, Any], value)
243
+ return {str(k): _make_powerfx_safe(v) for k, v in value_dict.items()}
244
+
245
+ if isinstance(value, list):
246
+ value_list = cast(list[Any], value)
247
+ return [_make_powerfx_safe(item) for item in value_list]
248
+
249
+ # Try to convert objects with __dict__ or dataclass-style attributes
250
+ if hasattr(value, "__dict__"):
251
+ return _make_powerfx_safe(vars(value))
252
+
253
+ # For other objects, try to convert to string representation
254
+ return str(value)
255
+
256
+
257
+ class DeclarativeWorkflowState:
258
+ """Manages workflow variables stored in State.
259
+
260
+ This class provides the same interface as the interpreter-based WorkflowState
261
+ but stores all data in State for checkpointing support.
262
+
263
+ The state is organized into namespaces (matching .NET naming conventions):
264
+ - Workflow.Inputs: Initial inputs (read-only)
265
+ - Workflow.Outputs: Values to return from workflow
266
+ - Local: Variables persisting within the workflow turn
267
+ - System: System-level variables (ConversationId, LastMessage, etc.)
268
+ - Agent: Results from most recent agent invocation
269
+ - Conversation: Conversation history
270
+ """
271
+
272
+ # Sentinel marking "no prior value" for temporary-key bookkeeping.
273
+ _MISSING: Any = object()
274
+
275
+ def __init__(self, state: State, env_config: DeclarativeEnvConfig | None = None):
276
+ """Initialize with a State instance.
277
+
278
+ Args:
279
+ state: The workflow's state for persistence
280
+ env_config: Configuration that populates the PowerFx ``Env``
281
+ symbol when ``_to_powerfx_symbols`` is called. Defaults to
282
+ an empty configuration which results in no ``Env`` binding,
283
+ matching the safe default of the :class:`WorkflowFactory`.
284
+ """
285
+ self._state = state
286
+ self._env_config = env_config if env_config is not None else DeclarativeEnvConfig()
287
+
288
+ def initialize(self, inputs: Mapping[str, Any] | None = None) -> None:
289
+ """Initialize the declarative state with inputs.
290
+
291
+ Args:
292
+ inputs: Initial workflow inputs (become Workflow.Inputs.*)
293
+ """
294
+ conversation_id = str(uuid.uuid4())
295
+ state_data: DeclarativeStateData = {
296
+ "Inputs": dict(inputs) if inputs else {},
297
+ "Outputs": {},
298
+ "Local": {},
299
+ "System": {
300
+ "ConversationId": conversation_id,
301
+ "LastMessage": {"Text": "", "Id": ""},
302
+ "LastMessageText": "",
303
+ "LastMessageId": "",
304
+ "conversations": {
305
+ conversation_id: {"id": conversation_id, "messages": []},
306
+ },
307
+ },
308
+ "Agent": {},
309
+ "Conversation": {"messages": [], "history": []},
310
+ "Custom": {},
311
+ }
312
+ self._state.set(DECLARATIVE_STATE_KEY, state_data)
313
+
314
+ def get_state_data(self) -> DeclarativeStateData:
315
+ """Get the full state data dict from state."""
316
+ result = self._state.get(DECLARATIVE_STATE_KEY)
317
+ if result is None:
318
+ # Initialize if not present
319
+ self.initialize()
320
+ result = self._state.get(DECLARATIVE_STATE_KEY)
321
+ return cast(DeclarativeStateData, result)
322
+
323
+ def is_initialized(self) -> bool:
324
+ """Return True when declarative state has been initialized.
325
+
326
+ Useful for distinguishing a fresh start from a continuation: when
327
+ Workflow state preserves data across run() calls (multi-turn
328
+ scenarios), the start executor needs to avoid calling initialize()
329
+ and clobbering the prior turn's Conversation/Local/System data.
330
+ """
331
+ return self._state.get(DECLARATIVE_STATE_KEY) is not None
332
+
333
+ def set_state_data(self, data: DeclarativeStateData) -> None:
334
+ """Set the full state data dict in state."""
335
+ self._state.set(DECLARATIVE_STATE_KEY, data)
336
+
337
+ def get(self, path: str, default: Any = None) -> Any:
338
+ """Get a value from the state using a dot-notated path.
339
+
340
+ Dict-keyed segments may use arbitrary string keys (e.g. UUIDs in
341
+ ``System.conversations.<id>.messages``). Segments that would resolve
342
+ via object-attribute access must be valid declarative identifiers
343
+ (``[A-Za-z][A-Za-z0-9_]*``); other shapes return ``default``.
344
+
345
+ Args:
346
+ path: Dot-notated path like 'Local.results' or 'Workflow.Inputs.query'
347
+ default: Default value if path doesn't exist
348
+
349
+ Returns:
350
+ The value at the path, or default if not found or unreachable.
351
+ """
352
+ state_data = self.get_state_data()
353
+ parts = path.split(".")
354
+ if not parts or any(not p for p in parts):
355
+ return default
356
+
357
+ namespace = parts[0]
358
+ remaining = parts[1:]
359
+
360
+ # Handle Workflow.Inputs and Workflow.Outputs specially
361
+ if namespace == "Workflow" and remaining:
362
+ sub_namespace = remaining[0]
363
+ remaining = remaining[1:]
364
+ if sub_namespace == "Inputs":
365
+ obj: Any = state_data.get("Inputs", {})
366
+ elif sub_namespace == "Outputs":
367
+ obj = state_data.get("Outputs", {})
368
+ else:
369
+ return default
370
+ elif namespace == "Local":
371
+ obj = state_data.get("Local", {})
372
+ elif namespace == "System":
373
+ obj = state_data.get("System", {})
374
+ elif namespace == "Agent":
375
+ obj = state_data.get("Agent", {})
376
+ elif namespace == "Conversation":
377
+ obj = state_data.get("Conversation", {})
378
+ else:
379
+ # Try custom namespace
380
+ custom_data: dict[str, Any] = state_data.get("Custom", {})
381
+ obj = custom_data.get(namespace, default)
382
+ if obj is default:
383
+ return default
384
+
385
+ # Navigate the remaining path
386
+ for part in remaining:
387
+ if isinstance(obj, dict):
388
+ obj = obj.get(part, default) # type: ignore[union-attr]
389
+ if obj is default:
390
+ return default
391
+ else:
392
+ # Attribute access is only allowed for safe declarative identifiers.
393
+ if not _SAFE_PATH_SEGMENT_RE.match(part):
394
+ logger.warning(
395
+ "DeclarativeWorkflowState.get: rejecting attribute segment %r in path %r",
396
+ part,
397
+ path,
398
+ )
399
+ return default
400
+ if hasattr(obj, part): # type: ignore[arg-type]
401
+ obj = getattr(obj, part) # type: ignore[arg-type]
402
+ else:
403
+ return default
404
+
405
+ return obj # type: ignore[return-value]
406
+
407
+ def set(self, path: str, value: Any) -> None:
408
+ """Set a value in the state using a dot-notated path.
409
+
410
+ Args:
411
+ path: Dot-notated path like 'Local.results' or 'Workflow.Outputs.response'
412
+ value: The value to set
413
+
414
+ Raises:
415
+ ValueError: If ``path`` is empty or contains empty segments
416
+ (e.g. ``"Local."``, ``"Local..foo"``), or if attempting to set
417
+ ``Workflow.Inputs`` (which is read-only).
418
+ """
419
+ state_data = self.get_state_data()
420
+ parts = path.split(".")
421
+ if not parts or any(not p for p in parts):
422
+ raise ValueError(f"Invalid path {path!r}: empty segments are not allowed")
423
+
424
+ namespace = parts[0]
425
+ remaining = parts[1:]
426
+
427
+ # Determine target dict
428
+ if namespace == "Workflow":
429
+ if not remaining:
430
+ raise ValueError("Cannot set 'Workflow' directly; use 'Workflow.Outputs.*'")
431
+ sub_namespace = remaining[0]
432
+ remaining = remaining[1:]
433
+ if sub_namespace == "Inputs":
434
+ raise ValueError("Cannot modify Workflow.Inputs - they are read-only")
435
+ if sub_namespace == "Outputs":
436
+ target = state_data.setdefault("Outputs", {})
437
+ else:
438
+ raise ValueError(f"Unknown Workflow namespace: {sub_namespace}")
439
+ elif namespace == "Local":
440
+ target = state_data.setdefault("Local", {})
441
+ elif namespace == "System":
442
+ target = state_data.setdefault("System", {})
443
+ elif namespace == "Agent":
444
+ target = state_data.setdefault("Agent", {})
445
+ elif namespace == "Conversation":
446
+ target = cast(dict[str, Any], state_data).setdefault("Conversation", {})
447
+ else:
448
+ # Create or use custom namespace
449
+ custom = state_data.setdefault("Custom", {})
450
+ if namespace not in custom:
451
+ custom[namespace] = {}
452
+ target = custom[namespace]
453
+
454
+ if not remaining:
455
+ raise ValueError(f"Cannot replace entire namespace '{namespace}'")
456
+
457
+ # Navigate to parent, creating dicts as needed
458
+ for part in remaining[:-1]:
459
+ if part not in target:
460
+ target[part] = {}
461
+ target = target[part]
462
+
463
+ # Set the final value
464
+ target[remaining[-1]] = value
465
+ self.set_state_data(state_data)
466
+
467
+ def append(self, path: str, value: Any) -> None:
468
+ """Append a value to a list at the specified path.
469
+
470
+ If the path doesn't exist, creates a new list with the value.
471
+
472
+ Note: This operation is not atomic. In concurrent scenarios, use explicit
473
+ locking or consider using atomic operations at the storage layer.
474
+
475
+ Args:
476
+ path: Dot-notated path to a list
477
+ value: The value to append
478
+
479
+ Raises:
480
+ ValueError: If ``path`` is empty or contains empty segments
481
+ (e.g. ``"Local."``, ``"Local..foo"``), or if the existing
482
+ value at ``path`` is not a list.
483
+ """
484
+ parts = path.split(".")
485
+ if not parts or any(not p for p in parts):
486
+ raise ValueError(f"Invalid path {path!r}: empty segments are not allowed")
487
+
488
+ existing = self.get(path)
489
+ if existing is None:
490
+ self.set(path, [value])
491
+ elif isinstance(existing, list):
492
+ existing_list: list[Any] = list(existing) # type: ignore[arg-type]
493
+ existing_list.append(value)
494
+ self.set(path, existing_list)
495
+ else:
496
+ raise ValueError(f"Cannot append to non-list at path '{path}'")
497
+
498
+ def _clear_local_path(self, name: str) -> None:
499
+ """Remove ``name`` from the ``Local`` namespace, if present."""
500
+ state_data = self.get_state_data()
501
+ local = state_data.get("Local")
502
+ if local is None or name not in local:
503
+ return
504
+ local.pop(name, None)
505
+ self.set_state_data(state_data)
506
+
507
+ def eval(self, expression: str) -> Any:
508
+ """Evaluate a PowerFx expression with the current state.
509
+
510
+ Expressions starting with '=' are evaluated as PowerFx.
511
+ Other strings are returned as-is.
512
+
513
+ Handles special custom functions not supported by PowerFx:
514
+ - UserMessage(text): Creates a user message dict from text
515
+ - MessageText(messages): Extracts text from the last message
516
+
517
+ Args:
518
+ expression: The expression to evaluate
519
+
520
+ Returns:
521
+ The evaluated result. Returns None if the expression references
522
+ undefined variables (matching legacy fallback parser behavior).
523
+
524
+ Raises:
525
+ RuntimeError: If the powerfx package is not installed and the
526
+ expression requires PowerFx evaluation.
527
+ """
528
+ if not expression:
529
+ return expression
530
+
531
+ if not isinstance(expression, str):
532
+ return expression
533
+
534
+ if not expression.startswith("="):
535
+ return expression
536
+
537
+ # Strip the leading '=' for evaluation
538
+ formula = expression[1:]
539
+
540
+ # Handle custom functions not supported by PowerFx
541
+ # First check if the entire formula is a custom function
542
+ result = self._eval_custom_function(formula)
543
+ if result is not None:
544
+ return result
545
+
546
+ # Pre-process nested custom functions (e.g., Upper(MessageText(...)))
547
+ # and run PowerFx. The finally below restores any temporary state
548
+ # written during preprocessing, regardless of where execution exits.
549
+ temp_writes: list[tuple[str, Any]] = []
550
+
551
+ try:
552
+ formula = self._preprocess_custom_functions(formula, temp_writes)
553
+
554
+ if Engine is None:
555
+ raise RuntimeError(
556
+ f"PowerFx is not available (dotnet runtime not installed). "
557
+ f"Expression '={formula[:80]}' cannot be evaluated. "
558
+ f"Install dotnet and the powerfx package for full PowerFx support."
559
+ )
560
+
561
+ symbols = self._to_powerfx_symbols()
562
+ # Use setlocale(category) query form so we can restore the exact prior value.
563
+ # getlocale() returns a normalized tuple and is not always a lossless
564
+ # round-trip for setlocale across platforms/locales.
565
+ original_numeric_locale = locale.setlocale(locale.LC_NUMERIC)
566
+ try:
567
+ for locale_candidate in _POWERFX_NUMERIC_LOCALE_CANDIDATES:
568
+ try:
569
+ locale.setlocale(locale.LC_NUMERIC, locale_candidate)
570
+ break
571
+ except locale.Error:
572
+ continue
573
+
574
+ engine = Engine()
575
+ try:
576
+ from System.Globalization import ( # pyright: ignore[reportMissingImports]
577
+ CultureInfo, # pyright: ignore[reportUnknownVariableType]
578
+ )
579
+ except ImportError:
580
+ return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
581
+
582
+ original_culture = cast(Any, CultureInfo.CurrentCulture) # pyright: ignore[reportUnknownMemberType]
583
+ try:
584
+ CultureInfo.CurrentCulture = CultureInfo(_POWERFX_EVAL_LOCALE)
585
+ return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
586
+ finally:
587
+ CultureInfo.CurrentCulture = original_culture
588
+ except ValueError as e:
589
+ error_msg = str(e)
590
+ # Handle undefined variable errors gracefully by returning None
591
+ # This matches the behavior of the legacy fallback parser
592
+ if "isn't recognized" in error_msg or "Name isn't valid" in error_msg:
593
+ logger.debug(f"PowerFx: undefined variable in expression '{formula}', returning None")
594
+ return None
595
+ raise
596
+ finally:
597
+ locale.setlocale(locale.LC_NUMERIC, original_numeric_locale)
598
+ finally:
599
+ # Restore each temporary key to its prior value (or remove it).
600
+ for path, previous in reversed(temp_writes):
601
+ if previous is self._MISSING:
602
+ self._clear_local_path(path.removeprefix("Local."))
603
+ else:
604
+ self.set(path, previous)
605
+
606
+ def _eval_custom_function(self, formula: str) -> Any | None:
607
+ """Handle custom functions not supported by the Python PowerFx library.
608
+
609
+ The standard PowerFx library supports these functions but the Python wrapper
610
+ may have limitations. We also handle Copilot Studio-specific dialects.
611
+
612
+ Returns None if the formula is not a custom function call.
613
+ """
614
+ import re
615
+
616
+ # Concat/Concatenate - string concatenation
617
+ # In standard PowerFx, Concatenate is for strings, Concat is for tables.
618
+ # Copilot Studio uses Concat for strings, so we support both.
619
+ match = re.match(r"(?:Concat|Concatenate)\((.+)\)$", formula.strip())
620
+ if match:
621
+ args_str = match.group(1)
622
+ # Parse comma-separated arguments (handling nested parentheses)
623
+ args = self._parse_function_args(args_str)
624
+ evaluated_args: list[str] = []
625
+ for arg in args:
626
+ arg = arg.strip()
627
+ if arg.startswith('"') and arg.endswith('"'):
628
+ # String literal
629
+ evaluated_args.append(arg[1:-1])
630
+ elif arg.startswith("'") and arg.endswith("'"):
631
+ # Single-quoted string literal
632
+ evaluated_args.append(arg[1:-1])
633
+ else:
634
+ # Variable reference - evaluate it
635
+ result = self.eval(f"={arg}")
636
+ evaluated_args.append(str(result) if result is not None else "")
637
+ return "".join(evaluated_args)
638
+
639
+ # UserMessage(expr) - creates a user message dict
640
+ match = re.match(r"UserMessage\((.+)\)$", formula.strip())
641
+ if match:
642
+ inner_expr = match.group(1).strip()
643
+ # Evaluate the inner expression
644
+ text = self.eval(f"={inner_expr}")
645
+ return {"role": "user", "text": str(text) if text else ""}
646
+
647
+ # AgentMessage(expr) - creates an assistant message dict
648
+ match = re.match(r"AgentMessage\((.+)\)$", formula.strip())
649
+ if match:
650
+ inner_expr = match.group(1).strip()
651
+ text = self.eval(f"={inner_expr}")
652
+ return {"role": "assistant", "text": str(text) if text else ""}
653
+
654
+ # MessageText(expr) - extracts text from the last message
655
+ match = re.match(r"MessageText\((.+)\)$", formula.strip())
656
+ if match:
657
+ inner_expr = match.group(1).strip()
658
+ # Reuse the helper method for consistent text extraction
659
+ return self._eval_and_replace_message_text(inner_expr)
660
+
661
+ return None
662
+
663
+ def _preprocess_custom_functions(self, formula: str, temp_writes: list[tuple[str, Any]]) -> str:
664
+ """Pre-process custom functions nested inside other PowerFx functions.
665
+
666
+ Custom functions like MessageText() are not supported by the PowerFx engine.
667
+ When they appear nested inside other functions (e.g., Upper(MessageText(...))),
668
+ we need to evaluate them first and replace with the result.
669
+
670
+ For long strings (>500 chars), the result is stored in a temporary state variable
671
+ to avoid exceeding PowerFx's 1000 character expression limit. This is a limitation
672
+ of the Python PowerFx wrapper (powerfx package), which doesn't expose the
673
+ MaximumExpressionLength configuration that the .NET PowerFxConfig provides.
674
+ The .NET implementation defaults to 10,000 characters, while Python defaults to 1,000.
675
+
676
+ Args:
677
+ formula: The PowerFx formula to pre-process
678
+ temp_writes: Caller-owned list. Each write to a temporary key
679
+ appends a ``(path, previous_value)`` entry where
680
+ ``previous_value`` is the value at ``path`` before the write
681
+ or :attr:`_MISSING` if none. The caller must restore every
682
+ entry, including when this method raises mid-write.
683
+
684
+ Returns:
685
+ The rewritten formula.
686
+ """
687
+ import re
688
+
689
+ # Threshold for storing in state vs embedding as literal.
690
+ # The Python PowerFx wrapper defaults to a 1000 char expression limit (vs 10,000 in .NET).
691
+ # We use 500 to leave room for the rest of the expression around the replaced value.
692
+ MAX_INLINE_LENGTH = 500
693
+
694
+ temp_var_counter = 0
695
+
696
+ # Custom functions that need pre-processing: (regex pattern, handler)
697
+ custom_functions = [
698
+ (r"MessageText\(", self._eval_and_replace_message_text),
699
+ ]
700
+
701
+ for pattern, handler in custom_functions:
702
+ # Find all occurrences of the custom function
703
+ while True:
704
+ match = re.search(pattern, formula)
705
+ if not match:
706
+ break
707
+
708
+ # Find the matching closing parenthesis
709
+ start = match.start()
710
+ paren_start = match.end() - 1 # Position of opening (
711
+ depth = 1
712
+ pos = paren_start + 1
713
+ in_string = False
714
+ escape_next = False
715
+
716
+ while pos < len(formula) and depth > 0:
717
+ char = formula[pos]
718
+ if escape_next:
719
+ escape_next = False
720
+ pos += 1
721
+ continue
722
+ if char == "\\":
723
+ escape_next = True
724
+ pos += 1
725
+ continue
726
+ if char == '"' and not escape_next:
727
+ in_string = not in_string
728
+ elif not in_string:
729
+ if char == "(":
730
+ depth += 1
731
+ elif char == ")":
732
+ depth -= 1
733
+ pos += 1
734
+
735
+ if depth != 0:
736
+ # Malformed expression, skip
737
+ break
738
+
739
+ # Extract the inner expression (between parentheses)
740
+ end = pos
741
+ inner_expr = formula[paren_start + 1 : end - 1]
742
+
743
+ # Evaluate and get replacement
744
+ replacement = handler(inner_expr)
745
+
746
+ # Replace in formula
747
+ if isinstance(replacement, str):
748
+ if len(replacement) > MAX_INLINE_LENGTH:
749
+ # Store long results in an underscore-prefixed temp key;
750
+ # record the prior value so eval() can restore it.
751
+ temp_var_name = f"_TempMessageText{temp_var_counter}"
752
+ temp_var_counter += 1
753
+ temp_var_path = f"Local.{temp_var_name}"
754
+ temp_writes.append((temp_var_path, self.get(temp_var_path, default=self._MISSING)))
755
+ self.set(temp_var_path, replacement)
756
+ replacement_str = temp_var_path
757
+ logger.debug(
758
+ f"Stored long MessageText result ({len(replacement)} chars) "
759
+ f"in temp variable {temp_var_name}"
760
+ )
761
+ else:
762
+ # Short strings can be embedded directly
763
+ escaped = replacement.replace('"', '""')
764
+ replacement_str = f'"{escaped}"'
765
+ else:
766
+ replacement_str = str(replacement) if replacement is not None else '""'
767
+
768
+ formula = formula[:start] + replacement_str + formula[end:]
769
+
770
+ return formula
771
+
772
+ def _eval_and_replace_message_text(self, inner_expr: str) -> str:
773
+ """Evaluate MessageText() and return the text result.
774
+
775
+ Args:
776
+ inner_expr: The expression inside MessageText()
777
+
778
+ Returns:
779
+ The extracted text from the messages
780
+ """
781
+ messages: Any = self.eval(f"={inner_expr}")
782
+ if isinstance(messages, list) and messages:
783
+ message_list = cast(list[Any], messages)
784
+ last_msg: Any = message_list[-1]
785
+ if isinstance(last_msg, dict):
786
+ last_msg_dict = cast(dict[str, Any], last_msg)
787
+ # Try "text" key first (simple dict format)
788
+ if "text" in last_msg_dict:
789
+ return str(last_msg_dict["text"])
790
+ # Try extracting from "contents" (Message dict format)
791
+ # Message.text concatenates text from all TextContent items
792
+ contents_obj = last_msg_dict.get("contents", [])
793
+ if isinstance(contents_obj, list):
794
+ contents = cast(list[Any], contents_obj)
795
+ text_parts: list[str] = []
796
+ for content in contents:
797
+ if isinstance(content, dict):
798
+ content_dict = cast(dict[str, Any], content)
799
+ # TextContent has a "text" key
800
+ if content_dict.get("type") == "text" or "text" in content_dict:
801
+ text_parts.append(str(content_dict.get("text", "")))
802
+ else:
803
+ content_obj: object = content
804
+ if hasattr(content_obj, "text"):
805
+ text_parts.append(str(getattr(content_obj, "text", "")))
806
+ if text_parts:
807
+ return " ".join(text_parts)
808
+ return ""
809
+ last_msg_obj: object = last_msg
810
+ if hasattr(last_msg_obj, "text"):
811
+ return str(getattr(last_msg_obj, "text", ""))
812
+ return ""
813
+
814
+ def _parse_function_args(self, args_str: str) -> list[str]:
815
+ """Parse comma-separated function arguments, handling nested parentheses and strings."""
816
+ args: list[str] = []
817
+ current: list[str] = []
818
+ depth = 0
819
+ in_string = False
820
+ string_char: str | None = None
821
+
822
+ for char in args_str:
823
+ if char in ('"', "'") and not in_string:
824
+ in_string = True
825
+ string_char = char
826
+ current.append(char)
827
+ elif char == string_char and in_string:
828
+ in_string = False
829
+ string_char = None
830
+ current.append(char)
831
+ elif char == "(" and not in_string:
832
+ depth += 1
833
+ current.append(char)
834
+ elif char == ")" and not in_string:
835
+ depth -= 1
836
+ current.append(char)
837
+ elif char == "," and depth == 0 and not in_string:
838
+ args.append("".join(current).strip())
839
+ current = []
840
+ else:
841
+ current.append(char)
842
+
843
+ if current:
844
+ args.append("".join(current).strip())
845
+
846
+ return args
847
+
848
+ def _to_powerfx_symbols(self) -> dict[str, Any]:
849
+ """Convert the current state to a PowerFx symbols dictionary.
850
+
851
+ Uses .NET-style PascalCase names (System, Local, Workflow) matching
852
+ the .NET declarative workflow implementation.
853
+ """
854
+ state_data = self.get_state_data()
855
+ local_data = state_data.get("Local", {})
856
+ agent_data = state_data.get("Agent", {})
857
+ conversation_data = state_data.get("Conversation", {})
858
+ system_data = state_data.get("System", {})
859
+ inputs_data = state_data.get("Inputs", {})
860
+ outputs_data = state_data.get("Outputs", {})
861
+
862
+ symbols: dict[str, Any] = {
863
+ # .NET-style PascalCase names (matching .NET implementation)
864
+ "Workflow": {
865
+ "Inputs": inputs_data,
866
+ "Outputs": outputs_data,
867
+ },
868
+ "Local": local_data,
869
+ "Agent": agent_data,
870
+ "Conversation": conversation_data,
871
+ "System": system_data,
872
+ # Also expose inputs at top level for backward compatibility with =inputs.X syntax
873
+ "inputs": inputs_data,
874
+ # Custom namespaces
875
+ **state_data.get("Custom", {}),
876
+ }
877
+ # Resolve the ``Env`` symbol from the workflow-level
878
+ # :class:`DeclarativeEnvConfig`. When both ``values`` and the
879
+ # ``os.environ`` allowlist produce no entries the symbol is
880
+ # omitted so ``=Env.X`` falls back to the literal expression
881
+ # string (preserving the legacy "unbound identifier" behaviour).
882
+ env_bound = self._env_config.resolve()
883
+ if env_bound:
884
+ symbols["Env"] = env_bound
885
+ # Debug log the Local symbols to help diagnose type issues
886
+ if local_data:
887
+ for key, value in local_data.items():
888
+ logger.debug(
889
+ f"PowerFx symbol Local.{key}: type={type(value).__name__}, "
890
+ f"value_preview={str(value)[:100] if value else None}"
891
+ )
892
+ result = _make_powerfx_safe(symbols)
893
+ return cast(dict[str, Any], result)
894
+
895
+ def eval_if_expression(self, value: Any) -> Any:
896
+ """Evaluate a value if it's a PowerFx expression, otherwise return as-is."""
897
+ if isinstance(value, str):
898
+ return self.eval(value)
899
+ if isinstance(value, dict):
900
+ value_dict: dict[str, Any] = dict(value) # type: ignore[arg-type]
901
+ return {k: self.eval_if_expression(v) for k, v in value_dict.items()}
902
+ if isinstance(value, list):
903
+ value_list: list[Any] = list(value) # type: ignore[arg-type]
904
+ return [self.eval_if_expression(item) for item in value_list]
905
+ return value
906
+
907
+ def interpolate_string(self, text: str) -> str:
908
+ """Interpolate ``{Variable.Path}`` references in a string.
909
+
910
+ Captures brace-delimited tokens whose root segment is an identifier
911
+ (``[A-Za-z][A-Za-z0-9_]*``) followed by zero or more ``.`` separated
912
+ dict-key segments. Resolution is delegated to :meth:`get`; unresolved
913
+ tokens are replaced with the empty string. Tokens that do not look
914
+ like state paths (e.g. ``{foo-bar}``, ``{Ctrl+C}``) are left literal.
915
+
916
+ Args:
917
+ text: Text that may contain {Variable.Path} references
918
+
919
+ Returns:
920
+ Text with variables interpolated
921
+ """
922
+ import re
923
+
924
+ def replace_var(match: re.Match[str]) -> str:
925
+ var_path: str = match.group(1)
926
+ value = self.get(var_path)
927
+ return str(value) if value is not None else ""
928
+
929
+ # Root segment must be an identifier; follow-on segments accept any
930
+ # non-empty dict-key (e.g. ``_id``, ``1``, UUIDs). ``get()`` enforces
931
+ # per-segment safety on attribute traversal.
932
+ pattern = r"\{([A-Za-z][A-Za-z0-9_]*(?:\.[^{}\s.]+)*)\}"
933
+
934
+ result = text
935
+ for match in re.finditer(pattern, text):
936
+ replacement = replace_var(match)
937
+ result = result.replace(match.group(0), replacement, 1)
938
+
939
+ return result
940
+
941
+
942
+ # Message types for inter-executor communication
943
+ # These are defined before DeclarativeActionExecutor since it references them
944
+
945
+
946
+ class ActionTrigger:
947
+ """Message that triggers a declarative action executor.
948
+
949
+ This is sent between executors in the graph to pass control
950
+ and any action-specific data.
951
+ """
952
+
953
+ def __init__(self, data: Any = None):
954
+ """Initialize the action trigger.
955
+
956
+ Args:
957
+ data: Optional data to pass to the action
958
+ """
959
+ self.data = data
960
+
961
+
962
+ class ActionComplete:
963
+ """Message sent when a declarative action completes.
964
+
965
+ This is sent to downstream executors to continue the workflow.
966
+ """
967
+
968
+ def __init__(self, result: Any = None):
969
+ """Initialize the completion message.
970
+
971
+ Args:
972
+ result: Optional result from the action
973
+ """
974
+ self.result = result
975
+
976
+
977
+ @dataclass
978
+ class ConditionResult:
979
+ """Result of evaluating a condition (If/ConditionGroup).
980
+
981
+ This message is output by ConditionEvaluatorExecutor and ConditionGroupEvaluatorExecutor
982
+ to indicate which branch should be taken.
983
+ """
984
+
985
+ matched: bool
986
+ branch_index: int # Which branch matched (0 = first, -1 = else/default)
987
+ value: Any = None # The evaluated condition value
988
+
989
+
990
+ @dataclass
991
+ class LoopIterationResult:
992
+ """Result of a loop iteration step.
993
+
994
+ This message is output by ForeachInitExecutor and ForeachNextExecutor
995
+ to indicate whether the loop should continue.
996
+ """
997
+
998
+ has_next: bool
999
+ current_item: Any = None
1000
+ current_index: int = 0
1001
+
1002
+
1003
+ @dataclass
1004
+ class LoopControl:
1005
+ """Signal for loop control (break/continue).
1006
+
1007
+ This message is output by BreakLoopExecutor and ContinueLoopExecutor.
1008
+ """
1009
+
1010
+ action: Literal["break", "continue"]
1011
+
1012
+
1013
+ # Union type for any declarative action message - allows executors to accept
1014
+ # messages from triggers, completions, and control flow results
1015
+ DeclarativeMessage = ActionTrigger | ActionComplete | ConditionResult | LoopIterationResult | LoopControl
1016
+
1017
+
1018
+ class DeclarativeActionExecutor(Executor):
1019
+ """Base class for declarative action executors.
1020
+
1021
+ Each declarative action (SetValue, SendActivity, etc.) is implemented
1022
+ as a subclass of this executor. The executor receives an ActionInput
1023
+ message containing the action definition and state reference.
1024
+ """
1025
+
1026
+ def __init__(
1027
+ self,
1028
+ action_def: dict[str, Any],
1029
+ *,
1030
+ id: str | None = None,
1031
+ ):
1032
+ """Initialize the declarative action executor.
1033
+
1034
+ Args:
1035
+ action_def: The action definition from YAML
1036
+ id: Optional executor ID (defaults to action id or generated)
1037
+ """
1038
+ action_id = id or action_def.get("id") or f"{action_def.get('kind', 'action')}_{hash(str(action_def)) % 10000}"
1039
+ super().__init__(id=action_id, defer_discovery=True)
1040
+ self._action_def = action_def
1041
+ # The active :class:`DeclarativeEnvConfig` is stamped onto the
1042
+ # executor by :class:`DeclarativeWorkflowBuilder` after construction.
1043
+ # Defaults to an empty configuration so direct ``DeclarativeActionExecutor``
1044
+ # construction (e.g. in unit tests) doesn't expose ``os.environ``.
1045
+ self._declarative_env_config: DeclarativeEnvConfig = DeclarativeEnvConfig()
1046
+
1047
+ # Manually register handlers after initialization
1048
+ self._handlers = {}
1049
+ self._handler_specs = []
1050
+ self._discover_handlers()
1051
+ self._discover_response_handlers()
1052
+
1053
+ def set_declarative_env_config(self, env_config: DeclarativeEnvConfig) -> None:
1054
+ """Set the workflow-level :class:`DeclarativeEnvConfig` for this executor.
1055
+
1056
+ Called by :class:`DeclarativeWorkflowBuilder` after each executor is
1057
+ created so that ``_to_powerfx_symbols`` populates the ``Env`` symbol
1058
+ according to the caller-supplied configuration on the
1059
+ :class:`WorkflowFactory`.
1060
+ """
1061
+ self._declarative_env_config = env_config
1062
+
1063
+ @property
1064
+ def action_def(self) -> dict[str, Any]:
1065
+ """Get the action definition."""
1066
+ return self._action_def
1067
+
1068
+ @property
1069
+ def display_name(self) -> str | None:
1070
+ """Get the display name for logging."""
1071
+ return self._action_def.get("displayName")
1072
+
1073
+ def _get_state(self, state: State) -> DeclarativeWorkflowState:
1074
+ """Get the declarative workflow state wrapper."""
1075
+ return DeclarativeWorkflowState(state, env_config=self._declarative_env_config)
1076
+
1077
+ async def _ensure_state_initialized(
1078
+ self,
1079
+ ctx: WorkflowContext[Any, Any],
1080
+ trigger: Any,
1081
+ ) -> DeclarativeWorkflowState:
1082
+ """Ensure declarative state is initialized.
1083
+
1084
+ Follows .NET's DefaultTransform pattern - accepts any input type:
1085
+ - dict/Mapping: Used directly as workflow.inputs
1086
+ - str: Converted to {"input": value}
1087
+ - list[Message]: Treated as the agent-facing message contract
1088
+ (e.g. from WorkflowAgent / as_agent()). The prior conversation
1089
+ history is stored in ``Conversation.messages``/
1090
+ ``Conversation.history`` and mirrored to
1091
+ ``System.conversations.{id}.messages`` so workflows that
1092
+ reference ``=Conversation.messages`` (e.g. InvokeAzureAgent) see
1093
+ assistant turns and other earlier messages, including non-text
1094
+ content. At the start of a turn this history excludes the current
1095
+ user message; that message's text is instead used as the string
1096
+ input (``Inputs.input``) and surfaced via ``System.LastMessage*``
1097
+ for backward compatibility with simple text-only workflows. Agent
1098
+ executors are responsible for appending the current user message
1099
+ to ``Conversation.messages`` immediately before invoking the
1100
+ inner agent.
1101
+ - DeclarativeMessage: Internal message, no initialization needed
1102
+ - Any other type: Converted via str() to {"input": str(value)}
1103
+
1104
+ Args:
1105
+ ctx: The workflow context
1106
+ trigger: The trigger message - can be any type
1107
+
1108
+ Returns:
1109
+ The initialized DeclarativeWorkflowState
1110
+ """
1111
+ state = self._get_state(ctx.state)
1112
+
1113
+ if isinstance(trigger, dict):
1114
+ # Structured inputs - use directly
1115
+ state.initialize(trigger) # type: ignore
1116
+ elif isinstance(trigger, list) and all(isinstance(m, Message) for m in trigger): # pyright: ignore[reportUnknownVariableType]
1117
+ # list[Message] (e.g. from WorkflowAgent / as_agent()).
1118
+ messages_list = cast(list[Message], trigger)
1119
+
1120
+ # Detect continuation: if the workflow's shared state already
1121
+ # carries declarative data from a prior turn (because the host
1122
+ # restored a checkpoint and dispatched this run with
1123
+ # reset_context=False), we MUST NOT call state.initialize() -
1124
+ # that would wipe Conversation.messages, Local.*, System.* etc.
1125
+ # Instead, treat the trigger as the new turn's user input only:
1126
+ # update Inputs.input, append the new user message to existing
1127
+ # Conversation history, and refresh System.LastMessage*.
1128
+ #
1129
+ # Continuation = declarative state already exists in the workflow's
1130
+ # shared state (either left over in-memory from a prior turn on
1131
+ # the same instance, or restored from a checkpoint just before
1132
+ # this run). In that case state.initialize() would wipe Local.*,
1133
+ # System.*, Conversation.* etc., destroying the cross-turn
1134
+ # context we're trying to preserve.
1135
+ is_continuation = state.is_initialized()
1136
+
1137
+ # Locate the trailing user message in the trigger.
1138
+ last_user_index = -1
1139
+ for idx in range(len(messages_list) - 1, -1, -1):
1140
+ if str(messages_list[idx].role).lower() == "user":
1141
+ last_user_index = idx
1142
+ break
1143
+
1144
+ if last_user_index >= 0:
1145
+ last_user_msg = messages_list[last_user_index]
1146
+ last_user_text = last_user_msg.text or ""
1147
+ last_user_id = getattr(last_user_msg, "message_id", "") or ""
1148
+ history_messages = messages_list[:last_user_index] + messages_list[last_user_index + 1 :]
1149
+ else:
1150
+ history_messages = list(messages_list)
1151
+ tail = messages_list[-1] if messages_list else None
1152
+ last_user_text = (tail.text or "") if tail is not None else ""
1153
+ last_user_id = getattr(tail, "message_id", "") or "" if tail is not None else ""
1154
+
1155
+ if is_continuation:
1156
+ # Continuation turn: keep prior Conversation.messages intact.
1157
+ # Refresh inputs and surface the new user message via the
1158
+ # System.LastMessage* fields. We deliberately do NOT append
1159
+ # the new user message to Conversation.messages here: agent
1160
+ # executors append the live user input themselves before
1161
+ # invoking the inner agent (matching the first-turn
1162
+ # contract where Conversation.messages holds prior turns
1163
+ # only).
1164
+ #
1165
+ # Note: ``state.set("Inputs.input", ...)`` would route to
1166
+ # the Custom namespace (Inputs is not a recognized top-level
1167
+ # writable namespace - see DeclarativeWorkflowState.set).
1168
+ # PowerFx expressions like ``=Workflow.Inputs.input`` /
1169
+ # ``=inputs.input`` read state_data["Inputs"] directly, so
1170
+ # we update that dict in place via get_state_data /
1171
+ # set_state_data.
1172
+ state_data = state.get_state_data()
1173
+ inputs_dict = state_data.get("Inputs")
1174
+ if not isinstance(inputs_dict, dict):
1175
+ inputs_dict = {}
1176
+ state_data["Inputs"] = inputs_dict
1177
+ inputs_dict["input"] = last_user_text
1178
+ state.set_state_data(state_data)
1179
+ # Trailing non-user messages (e.g. tool results) sandwiched
1180
+ # before the new user message in the trigger are still
1181
+ # appended so later actions see them.
1182
+ for msg in history_messages:
1183
+ state.append("Conversation.messages", msg)
1184
+ state.append("Conversation.history", msg)
1185
+ conversation_id = state.get("System.ConversationId")
1186
+ if conversation_id:
1187
+ conv_path = f"System.conversations.{conversation_id}.messages"
1188
+ for msg in history_messages:
1189
+ state.append(conv_path, msg)
1190
+ state.set("System.LastMessage", {"Text": last_user_text, "Id": last_user_id})
1191
+ state.set("System.LastMessageText", last_user_text)
1192
+ state.set("System.LastMessageId", last_user_id)
1193
+ else:
1194
+ # First turn: full initialization.
1195
+ state.initialize({"input": last_user_text})
1196
+
1197
+ for msg in history_messages:
1198
+ state.append("Conversation.messages", msg)
1199
+ state.append("Conversation.history", msg)
1200
+
1201
+ conversation_id = state.get("System.ConversationId")
1202
+ if conversation_id:
1203
+ conv_path = f"System.conversations.{conversation_id}.messages"
1204
+ for msg in history_messages:
1205
+ state.append(conv_path, msg)
1206
+
1207
+ state.set("System.LastMessage", {"Text": last_user_text, "Id": last_user_id})
1208
+ state.set("System.LastMessageText", last_user_text)
1209
+ state.set("System.LastMessageId", last_user_id)
1210
+ elif isinstance(trigger, str):
1211
+ # String input - wrap in dict and populate System.LastMessage.Text
1212
+ # so YAML expressions like =System.LastMessage.Text see the user input
1213
+ state.initialize({"input": trigger})
1214
+ state.set("System.LastMessage", {"Text": trigger, "Id": ""})
1215
+ state.set("System.LastMessageText", trigger)
1216
+ elif not isinstance(
1217
+ trigger,
1218
+ (ActionTrigger, ActionComplete, ConditionResult, LoopIterationResult, LoopControl),
1219
+ ):
1220
+ # Any other type - convert to string like .NET's DefaultTransform
1221
+ input_str = str(cast(Any, trigger))
1222
+ state.initialize({"input": input_str})
1223
+ state.set("System.LastMessage", {"Text": input_str, "Id": ""})
1224
+ state.set("System.LastMessageText", input_str)
1225
+
1226
+ return state