Skip to content

Commit e793794

Browse files
authored
Python: Bug fix for declarative workflows (#6468)
* Fix declarative object parsing bug * Remove unnecessary comment * Address PR comments * Address PR comments. * Fix CI failures.
1 parent 3d5421e commit e793794

3 files changed

Lines changed: 490 additions & 65 deletions

File tree

python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py

Lines changed: 122 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@
6363

6464
_ENV_REFERENCE_RE = re.compile(r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)")
6565

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+
6669

6770
@dataclass(frozen=True)
6871
class DeclarativeEnvConfig:
@@ -266,6 +269,9 @@ class DeclarativeWorkflowState:
266269
- Conversation: Conversation history
267270
"""
268271

272+
# Sentinel marking "no prior value" for temporary-key bookkeeping.
273+
_MISSING: Any = object()
274+
269275
def __init__(self, state: State, env_config: DeclarativeEnvConfig | None = None):
270276
"""Initialize with a State instance.
271277
@@ -331,16 +337,21 @@ def set_state_data(self, data: DeclarativeStateData) -> None:
331337
def get(self, path: str, default: Any = None) -> Any:
332338
"""Get a value from the state using a dot-notated path.
333339
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+
334345
Args:
335346
path: Dot-notated path like 'Local.results' or 'Workflow.Inputs.query'
336347
default: Default value if path doesn't exist
337348
338349
Returns:
339-
The value at the path, or default if not found
350+
The value at the path, or default if not found or unreachable.
340351
"""
341352
state_data = self.get_state_data()
342353
parts = path.split(".")
343-
if not parts:
354+
if not parts or any(not p for p in parts):
344355
return default
345356

346357
namespace = parts[0]
@@ -377,10 +388,19 @@ def get(self, path: str, default: Any = None) -> Any:
377388
obj = obj.get(part, default) # type: ignore[union-attr]
378389
if obj is default:
379390
return default
380-
elif hasattr(obj, part): # type: ignore[arg-type]
381-
obj = getattr(obj, part) # type: ignore[arg-type]
382391
else:
383-
return default
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
384404

385405
return obj # type: ignore[return-value]
386406

@@ -392,12 +412,14 @@ def set(self, path: str, value: Any) -> None:
392412
value: The value to set
393413
394414
Raises:
395-
ValueError: If attempting to set Workflow.Inputs (which is read-only)
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).
396418
"""
397419
state_data = self.get_state_data()
398420
parts = path.split(".")
399-
if not parts:
400-
return
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")
401423

402424
namespace = parts[0]
403425
remaining = parts[1:]
@@ -453,7 +475,16 @@ def append(self, path: str, value: Any) -> None:
453475
Args:
454476
path: Dot-notated path to a list
455477
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.
456483
"""
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+
457488
existing = self.get(path)
458489
if existing is None:
459490
self.set(path, [value])
@@ -464,6 +495,15 @@ def append(self, path: str, value: Any) -> None:
464495
else:
465496
raise ValueError(f"Cannot append to non-list at path '{path}'")
466497

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+
467507
def eval(self, expression: str) -> Any:
468508
"""Evaluate a PowerFx expression with the current state.
469509
@@ -504,53 +544,64 @@ def eval(self, expression: str) -> Any:
504544
return result
505545

506546
# Pre-process nested custom functions (e.g., Upper(MessageText(...)))
507-
# Replace them with their evaluated results before sending to PowerFx
508-
formula = self._preprocess_custom_functions(formula)
509-
510-
if Engine is None:
511-
raise RuntimeError(
512-
f"PowerFx is not available (dotnet runtime not installed). "
513-
f"Expression '={formula[:80]}' cannot be evaluated. "
514-
f"Install dotnet and the powerfx package for full PowerFx support."
515-
)
516-
517-
symbols = self._to_powerfx_symbols()
518-
# Use setlocale(category) query form so we can restore the exact prior value.
519-
# getlocale() returns a normalized tuple and is not always a lossless
520-
# round-trip for setlocale across platforms/locales.
521-
original_numeric_locale = locale.setlocale(locale.LC_NUMERIC)
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+
522551
try:
523-
for locale_candidate in _POWERFX_NUMERIC_LOCALE_CANDIDATES:
524-
try:
525-
locale.setlocale(locale.LC_NUMERIC, locale_candidate)
526-
break
527-
except locale.Error:
528-
continue
552+
formula = self._preprocess_custom_functions(formula, temp_writes)
529553

530-
engine = Engine()
531-
try:
532-
from System.Globalization import ( # pyright: ignore[reportMissingImports]
533-
CultureInfo, # pyright: ignore[reportUnknownVariableType]
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."
534559
)
535-
except ImportError:
536-
return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
537560

538-
original_culture = cast(Any, CultureInfo.CurrentCulture) # pyright: ignore[reportUnknownMemberType]
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)
539566
try:
540-
CultureInfo.CurrentCulture = CultureInfo(_POWERFX_EVAL_LOCALE) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
541-
return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
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) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
585+
return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
586+
finally:
587+
CultureInfo.CurrentCulture = original_culture # pyright: ignore[reportUnknownMemberType]
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
542596
finally:
543-
CultureInfo.CurrentCulture = original_culture # pyright: ignore[reportUnknownMemberType]
544-
except ValueError as e:
545-
error_msg = str(e)
546-
# Handle undefined variable errors gracefully by returning None
547-
# This matches the behavior of the legacy fallback parser
548-
if "isn't recognized" in error_msg or "Name isn't valid" in error_msg:
549-
logger.debug(f"PowerFx: undefined variable in expression '{formula}', returning None")
550-
return None
551-
raise
597+
locale.setlocale(locale.LC_NUMERIC, original_numeric_locale)
552598
finally:
553-
locale.setlocale(locale.LC_NUMERIC, original_numeric_locale)
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)
554605

555606
def _eval_custom_function(self, formula: str) -> Any | None:
556607
"""Handle custom functions not supported by the Python PowerFx library.
@@ -609,7 +660,7 @@ def _eval_custom_function(self, formula: str) -> Any | None:
609660

610661
return None
611662

612-
def _preprocess_custom_functions(self, formula: str) -> str:
663+
def _preprocess_custom_functions(self, formula: str, temp_writes: list[tuple[str, Any]]) -> str:
613664
"""Pre-process custom functions nested inside other PowerFx functions.
614665
615666
Custom functions like MessageText() are not supported by the PowerFx engine.
@@ -624,9 +675,14 @@ def _preprocess_custom_functions(self, formula: str) -> str:
624675
625676
Args:
626677
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.
627683
628684
Returns:
629-
The formula with custom function calls replaced by their evaluated results
685+
The rewritten formula.
630686
"""
631687
import re
632688

@@ -635,7 +691,6 @@ def _preprocess_custom_functions(self, formula: str) -> str:
635691
# We use 500 to leave room for the rest of the expression around the replaced value.
636692
MAX_INLINE_LENGTH = 500
637693

638-
# Counter for generating unique temp variable names
639694
temp_var_counter = 0
640695

641696
# Custom functions that need pre-processing: (regex pattern, handler)
@@ -691,11 +746,14 @@ def _preprocess_custom_functions(self, formula: str) -> str:
691746
# Replace in formula
692747
if isinstance(replacement, str):
693748
if len(replacement) > MAX_INLINE_LENGTH:
694-
# Store long strings in a temp variable to avoid PowerFx expression limit
749+
# Store long results in an underscore-prefixed temp key;
750+
# record the prior value so eval() can restore it.
695751
temp_var_name = f"_TempMessageText{temp_var_counter}"
696752
temp_var_counter += 1
697-
self.set(f"Local.{temp_var_name}", replacement)
698-
replacement_str = f"Local.{temp_var_name}"
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
699757
logger.debug(
700758
f"Stored long MessageText result ({len(replacement)} chars) "
701759
f"in temp variable {temp_var_name}"
@@ -847,11 +905,13 @@ def eval_if_expression(self, value: Any) -> Any:
847905
return value
848906

849907
def interpolate_string(self, text: str) -> str:
850-
"""Interpolate {Variable.Path} references in a string.
908+
"""Interpolate ``{Variable.Path}`` references in a string.
851909
852-
This handles template-style variable substitution like:
853-
- "Created ticket #{Local.TicketParameters.TicketId}"
854-
- "Routing to {Local.RoutingParameters.TeamName}"
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.
855915
856916
Args:
857917
text: Text that may contain {Variable.Path} references
@@ -866,10 +926,11 @@ def replace_var(match: re.Match[str]) -> str:
866926
value = self.get(var_path)
867927
return str(value) if value is not None else ""
868928

869-
# Match {Variable.Path} patterns
870-
pattern = r"\{([A-Za-z][A-Za-z0-9_.]*)\}"
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.]+)*)\}"
871933

872-
# Replace all matches
873934
result = text
874935
for match in re.finditer(pattern, text):
875936
replacement = replace_var(match)

0 commit comments

Comments
 (0)