agent-framework-declarative 1.0.4__tar.gz → 1.2.0__tar.gz

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.
Files changed (29) hide show
  1. agent_framework_declarative-1.2.0/PKG-INFO +90 -0
  2. agent_framework_declarative-1.2.0/README.md +63 -0
  3. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_loader.py +6 -4
  4. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_declarative_base.py +189 -116
  5. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_executors_agents.py +73 -51
  6. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_executors_basic.py +8 -5
  7. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_executors_http.py +5 -33
  8. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_executors_mcp.py +81 -27
  9. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_executors_tools.py +17 -8
  10. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_factory.py +7 -4
  11. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_http_handler.py +46 -43
  12. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_mcp_handler.py +142 -51
  13. agent_framework_declarative-1.2.0/agent_framework_declarative/_workflows/_powerfx_limits.py +76 -0
  14. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_state.py +49 -9
  15. agent_framework_declarative-1.2.0/agent_framework_declarative/_workflows/_state_path.py +12 -0
  16. agent_framework_declarative-1.2.0/agent_framework_declarative/py.typed +0 -0
  17. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/pyproject.toml +3 -3
  18. agent_framework_declarative-1.0.4/PKG-INFO +0 -49
  19. agent_framework_declarative-1.0.4/README.md +0 -22
  20. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/LICENSE +0 -0
  21. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/__init__.py +0 -0
  22. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_feature_usage.py +0 -0
  23. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_models.py +0 -0
  24. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/__init__.py +0 -0
  25. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_declarative_builder.py +0 -0
  26. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_errors.py +0 -0
  27. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_executors_control_flow.py +0 -0
  28. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_executors_external_input.py +0 -0
  29. {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_powerfx_functions.py +0 -0
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-framework-declarative
3
+ Version: 1.2.0
4
+ Summary: Declarative specification support for Microsoft Agent Framework.
5
+ Author-email: Microsoft <af-support@microsoft.com>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Typing :: Typed
17
+ License-File: LICENSE
18
+ Requires-Dist: agent-framework-core>=1.20.0,<2
19
+ Requires-Dist: httpx>=0.27,<1
20
+ Requires-Dist: powerfx>=0.0.32,<0.0.35; python_version < '3.14'
21
+ Requires-Dist: pyyaml>=6.0,<7.0
22
+ Project-URL: homepage, https://aka.ms/agent-framework
23
+ Project-URL: issues, https://github.com/microsoft/agent-framework/issues
24
+ Project-URL: release_notes, https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true
25
+ Project-URL: source, https://github.com/microsoft/agent-framework/tree/main/python
26
+
27
+ # Get Started with Microsoft Agent Framework Declarative
28
+
29
+ Please install this package via pip:
30
+
31
+ ```bash
32
+ pip install agent-framework-declarative
33
+ ```
34
+
35
+ ## Release stage
36
+
37
+ This package ships at two different stability levels:
38
+
39
+ - **Declarative workflows** (`WorkflowFactory`, executors, handlers, and the
40
+ `_workflows` surface) are **stable**.
41
+ - **Declarative agents** (`AgentFactory` and the YAML agent loading/parsing path:
42
+ `DeclarativeLoaderError`, `ProviderLookupError`, `ProviderTypeMapping`) are
43
+ **experimental** and may change or be removed in future versions without notice.
44
+ Using any of these symbols emits an `ExperimentalWarning` on first use.
45
+
46
+ ## Declarative features
47
+
48
+ The declarative packages provides support for building agents based on a declarative yaml specification.
49
+
50
+ ## HTTP request client ownership and cookies
51
+
52
+ **Breaking change:** The HTTP client created by `DefaultHttpRequestHandler` no longer
53
+ persists response cookies. The handler still creates its client lazily, reuses it across
54
+ requests and workflows, and closes it on `aclose()` or async context-manager exit.
55
+ Timeout and redirect defaults are unchanged.
56
+
57
+ Applications requiring cookies for authentication, session continuity, or load-balancer
58
+ affinity must supply an `httpx.AsyncClient` through `client=` or `client_provider=`.
59
+ Supplied and provider-returned clients retain their configuration and cookie behavior
60
+ and must be closed by the caller. Scope cookie-bearing clients to one authenticated
61
+ principal; sharing a handler across workflows does not partition a client's cookies.
62
+ A provider returning `None` falls back to `client=`, if supplied, then to the internally
63
+ owned client with the default cookie policy.
64
+
65
+ Explicit `Cookie` request headers remain supported. Response `Set-Cookie` headers
66
+ remain available in `HttpRequestResult.headers`; disabling persistence does not redact
67
+ the response.
68
+
69
+ ## MCP handler session lifetime
70
+
71
+ `DefaultMCPToolHandler()` reuses MCP sessions through a bounded LRU cache.
72
+ When configured with `client_provider`, it instead invokes the provider and
73
+ creates a fresh MCP tool/session for every invocation, including `tools/list`.
74
+ Sessions are never reused in this mode, even if the provider returns `None`
75
+ or the same `httpx.AsyncClient` instance.
76
+
77
+ Each invocation closes its tool/session on success, failure, or cancellation.
78
+ Internally created fallback HTTP clients are also closed; caller-supplied HTTP
79
+ clients remain caller-owned and are never closed by the handler. Calling
80
+ `aclose()` rejects new invocations and waits for active provider-backed
81
+ invocations to finish cleaning up. Calling it from an active invocation's
82
+ context (including provider callbacks, inherited child tasks, and cleanup)
83
+ raises `RuntimeError` before changing handler state, rather than waiting on
84
+ itself. Close the handler outside that context or after the invocation completes.
85
+
86
+ This intentionally incurs connection and initialization overhead and does not
87
+ retain server session state between provider-backed invocations. Applications
88
+ that require shared session ownership must implement an explicitly scoped
89
+ custom `MCPToolHandler`; there is no provider-backed session-cache opt-in.
90
+
@@ -0,0 +1,63 @@
1
+ # Get Started with Microsoft Agent Framework Declarative
2
+
3
+ Please install this package via pip:
4
+
5
+ ```bash
6
+ pip install agent-framework-declarative
7
+ ```
8
+
9
+ ## Release stage
10
+
11
+ This package ships at two different stability levels:
12
+
13
+ - **Declarative workflows** (`WorkflowFactory`, executors, handlers, and the
14
+ `_workflows` surface) are **stable**.
15
+ - **Declarative agents** (`AgentFactory` and the YAML agent loading/parsing path:
16
+ `DeclarativeLoaderError`, `ProviderLookupError`, `ProviderTypeMapping`) are
17
+ **experimental** and may change or be removed in future versions without notice.
18
+ Using any of these symbols emits an `ExperimentalWarning` on first use.
19
+
20
+ ## Declarative features
21
+
22
+ The declarative packages provides support for building agents based on a declarative yaml specification.
23
+
24
+ ## HTTP request client ownership and cookies
25
+
26
+ **Breaking change:** The HTTP client created by `DefaultHttpRequestHandler` no longer
27
+ persists response cookies. The handler still creates its client lazily, reuses it across
28
+ requests and workflows, and closes it on `aclose()` or async context-manager exit.
29
+ Timeout and redirect defaults are unchanged.
30
+
31
+ Applications requiring cookies for authentication, session continuity, or load-balancer
32
+ affinity must supply an `httpx.AsyncClient` through `client=` or `client_provider=`.
33
+ Supplied and provider-returned clients retain their configuration and cookie behavior
34
+ and must be closed by the caller. Scope cookie-bearing clients to one authenticated
35
+ principal; sharing a handler across workflows does not partition a client's cookies.
36
+ A provider returning `None` falls back to `client=`, if supplied, then to the internally
37
+ owned client with the default cookie policy.
38
+
39
+ Explicit `Cookie` request headers remain supported. Response `Set-Cookie` headers
40
+ remain available in `HttpRequestResult.headers`; disabling persistence does not redact
41
+ the response.
42
+
43
+ ## MCP handler session lifetime
44
+
45
+ `DefaultMCPToolHandler()` reuses MCP sessions through a bounded LRU cache.
46
+ When configured with `client_provider`, it instead invokes the provider and
47
+ creates a fresh MCP tool/session for every invocation, including `tools/list`.
48
+ Sessions are never reused in this mode, even if the provider returns `None`
49
+ or the same `httpx.AsyncClient` instance.
50
+
51
+ Each invocation closes its tool/session on success, failure, or cancellation.
52
+ Internally created fallback HTTP clients are also closed; caller-supplied HTTP
53
+ clients remain caller-owned and are never closed by the handler. Calling
54
+ `aclose()` rejects new invocations and waits for active provider-backed
55
+ invocations to finish cleaning up. Calling it from an active invocation's
56
+ context (including provider callbacks, inherited child tasks, and cleanup)
57
+ raises `RuntimeError` before changing handler state, rather than waiting on
58
+ itself. Close the handler outside that context or after the invocation completes.
59
+
60
+ This intentionally incurs connection and initialization overhead and does not
61
+ retain server session state between provider-backed invocations. Applications
62
+ that require shared session ownership must implement an explicitly scoped
63
+ custom `MCPToolHandler`; there is no provider-backed session-cache opt-in.
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import sys
6
7
  from collections.abc import Callable, Mapping
7
8
  from pathlib import Path
@@ -338,7 +339,7 @@ class AgentFactory:
338
339
  yaml_path = Path(yaml_path)
339
340
  if not yaml_path.exists():
340
341
  raise DeclarativeLoaderError(f"YAML file not found at path: {yaml_path}")
341
- with open(yaml_path) as f:
342
+ with open(yaml_path, encoding="utf-8") as f:
342
343
  yaml_str = f.read()
343
344
  return self.create_agent_from_yaml(yaml_str)
344
345
 
@@ -508,9 +509,10 @@ class AgentFactory:
508
509
  """
509
510
  if not isinstance(yaml_path, Path):
510
511
  yaml_path = Path(yaml_path)
511
- if not yaml_path.exists():
512
- raise DeclarativeLoaderError(f"YAML file not found at path: {yaml_path}")
513
- yaml_str = yaml_path.read_text()
512
+ try:
513
+ yaml_str = await asyncio.to_thread(yaml_path.read_text, encoding="utf-8")
514
+ except FileNotFoundError as exc:
515
+ raise DeclarativeLoaderError(f"YAML file not found at path: {yaml_path}") from exc
514
516
  return await self.create_agent_from_yaml_async(yaml_str)
515
517
 
516
518
  async def create_agent_from_yaml_async(self, yaml_str: str) -> Agent:
@@ -31,7 +31,7 @@ import os
31
31
  import re
32
32
  import sys
33
33
  import uuid
34
- from collections.abc import Mapping
34
+ from collections.abc import Iterator, Mapping
35
35
  from dataclasses import dataclass, field
36
36
  from decimal import Decimal as _Decimal
37
37
  from enum import Enum
@@ -45,6 +45,10 @@ from agent_framework import (
45
45
  )
46
46
  from agent_framework._workflows._state import State
47
47
 
48
+ from ._errors import DeclarativeWorkflowError
49
+ from ._powerfx_limits import _PowerFxStateBudget, _validate_powerfx_state # pyright: ignore[reportPrivateUsage]
50
+ from ._state_path import _is_safe_path_segment # pyright: ignore[reportPrivateUsage]
51
+
48
52
  try:
49
53
  from powerfx import Engine
50
54
  except (ImportError, RuntimeError):
@@ -63,8 +67,72 @@ logger = logging.getLogger(__name__)
63
67
 
64
68
  _ENV_REFERENCE_RE = re.compile(r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)")
65
69
 
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_]*$")
70
+
71
+ def _skip_powerfx_opaque_token(formula: str, start: int) -> int:
72
+ """Return the end of an ordinary quoted token or comment, or start if neither."""
73
+ quote = formula[start]
74
+ if quote in ('"', "'"):
75
+ pos = start + 1
76
+ while pos < len(formula):
77
+ if formula[pos] == quote:
78
+ if pos + 1 < len(formula) and formula[pos + 1] == quote:
79
+ pos += 2
80
+ continue
81
+ return pos + 1
82
+ pos += 1
83
+ return pos
84
+ if formula.startswith("//", start):
85
+ pos = start + 2
86
+ while pos < len(formula) and formula[pos] not in "\r\n":
87
+ pos += 1
88
+ return pos
89
+ if formula.startswith("/*", start):
90
+ end = formula.find("*/", start + 2)
91
+ return len(formula) if end == -1 else end + 2
92
+ return start
93
+
94
+
95
+ def _iter_powerfx_expression_indices(formula: str) -> Iterator[int]:
96
+ """Yield code positions, excluding literals/comments but including interpolation expressions."""
97
+ # None denotes interpolated text; integers track record braces in expression sections.
98
+ scopes: list[int | None] = [0]
99
+ cursor = 0
100
+ while cursor < len(formula):
101
+ depth = scopes[-1]
102
+ char = formula[cursor]
103
+ if depth is None:
104
+ if formula.startswith(('""', "{{", "}}"), cursor):
105
+ cursor += 2
106
+ continue
107
+ if char == "{":
108
+ scopes.append(0)
109
+ elif char == '"':
110
+ scopes.pop()
111
+ cursor += 1
112
+ continue
113
+
114
+ if formula.startswith('$"', cursor):
115
+ scopes.append(None)
116
+ cursor += 2
117
+ continue
118
+
119
+ token_end = _skip_powerfx_opaque_token(formula, cursor)
120
+ if token_end != cursor:
121
+ cursor = token_end
122
+ continue
123
+
124
+ if char == "{":
125
+ scopes[-1] = depth + 1
126
+ elif char == "}":
127
+ if depth > 0:
128
+ scopes[-1] = depth - 1
129
+ elif len(scopes) > 1:
130
+ scopes.pop()
131
+ cursor += 1
132
+ continue
133
+
134
+ yield cursor
135
+ cursor += 1
68
136
 
69
137
 
70
138
  @dataclass(frozen=True)
@@ -100,6 +168,8 @@ class DeclarativeEnvConfig:
100
168
  # contents of ``values`` / ``referenced_names``: caller mutations
101
169
  # to the original objects after construction cannot leak into
102
170
  # ``resolve()``.
171
+ _validate_powerfx_state(self.values)
172
+ _validate_powerfx_state(self.referenced_names)
103
173
  object.__setattr__(self, "values", MappingProxyType(dict(self.values)))
104
174
  object.__setattr__(self, "referenced_names", frozenset(self.referenced_names))
105
175
 
@@ -112,6 +182,8 @@ class DeclarativeEnvConfig:
112
182
  unrelated environment variables never enter the PowerFx scope.
113
183
  Configuration values always win over the environment fallback.
114
184
  """
185
+ _validate_powerfx_state(self.values)
186
+ _validate_powerfx_state(self.referenced_names)
115
187
  resolved = {name: str(value) for name, value in self.values.items()}
116
188
  if self.restrict_to_configuration:
117
189
  return resolved
@@ -130,6 +202,10 @@ def discover_env_references(node: Any) -> set[str]:
130
202
  happen to mention ``Env.SOMETHING`` as plain text, the scan only inspects
131
203
  strings that begin with ``=`` (PowerFx expression marker, matching the
132
204
  convention enforced by :meth:`DeclarativeWorkflowState.eval`).
205
+ Shared containers are scanned once by identity without Python recursion.
206
+ Cyclic mappings and lists are rejected rather than silently skipped.
207
+ This avoids repeated container traversal, but does not impose a definition-size,
208
+ depth, parsing-time, or execution budget.
133
209
 
134
210
  Args:
135
211
  node: A parsed workflow definition (typically the dict produced by
@@ -138,23 +214,43 @@ def discover_env_references(node: Any) -> set[str]:
138
214
  Returns:
139
215
  The set of ``Env`` identifier names referenced in PowerFx
140
216
  expressions inside ``node``.
217
+
218
+ Raises:
219
+ DeclarativeWorkflowError: If the definition contains a mapping/list cycle.
141
220
  """
142
221
  names: set[str] = set()
222
+ active: set[int] = set()
223
+ # Retain containers so their identities cannot be reused during the walk.
224
+ visited: dict[int, Mapping[Any, Any] | list[Any]] = {}
225
+ stack: list[tuple[int | None, Iterator[Any]]] = [(None, iter((node,)))]
143
226
 
144
- def visit(value: Any) -> None:
227
+ while stack:
228
+ parent_id, children = stack[-1]
229
+ try:
230
+ value = next(children)
231
+ except StopIteration:
232
+ stack.pop()
233
+ if parent_id is not None:
234
+ active.remove(parent_id)
235
+ continue
145
236
  if isinstance(value, str):
146
237
  if value.startswith("="):
147
238
  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)
239
+ continue
240
+ if not isinstance(value, (Mapping, list)):
241
+ continue
242
+
243
+ container = cast(Mapping[Any, Any] | list[Any], value)
244
+ container_id = id(container)
245
+ if container_id in active:
246
+ raise DeclarativeWorkflowError("Cyclic mappings or lists are not supported in workflow definitions.")
247
+ if container_id in visited:
248
+ continue
249
+ visited[container_id] = container
250
+ active.add(container_id)
251
+ children = iter(container.values()) if isinstance(container, Mapping) else iter(container)
252
+ stack.append((container_id, children))
156
253
 
157
- visit(node)
158
254
  return names
159
255
 
160
256
 
@@ -221,10 +317,16 @@ def _make_powerfx_safe(value: Any) -> Any:
221
317
 
222
318
  Returns:
223
319
  A PowerFx-safe representation of the value
320
+
321
+ Raises:
322
+ ValueError: If the input or converted symbols exceed the state budget.
224
323
  """
225
- if value is None:
226
- return value
324
+ _validate_powerfx_state(value)
325
+ return _convert_powerfx_value(value, _PowerFxStateBudget(), 0)
326
+
227
327
 
328
+ def _convert_powerfx_value(value: Any, budget: _PowerFxStateBudget, depth: int) -> Any:
329
+ """Convert an already bounded value without restarting its traversal budget."""
228
330
  # Enum coercion must run BEFORE the primitive type check: many MAF
229
331
  # enums (e.g. MessageRole) are ``str``-subclass enums, so they pass
230
332
  # ``isinstance(v, str)`` but pythonnet refuses to convert them to
@@ -233,25 +335,27 @@ def _make_powerfx_safe(value: Any) -> Any:
233
335
  # to the underlying value (or its string form) so PowerFx sees a
234
336
  # plain ``str``/``int``.
235
337
  if isinstance(value, Enum):
236
- return _make_powerfx_safe(value.value)
338
+ return _convert_powerfx_value(value.value, budget, depth)
237
339
 
340
+ if not isinstance(value, (*_POWERFX_SAFE_TYPES, dict, list)):
341
+ if hasattr(value, "__dict__"):
342
+ return _convert_powerfx_value(vars(value), budget, depth)
343
+ value = str(value)
344
+
345
+ budget.consume(value, depth)
238
346
  if isinstance(value, _POWERFX_SAFE_TYPES):
239
347
  return value
240
348
 
241
349
  if isinstance(value, dict):
242
350
  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))
351
+ result: dict[str, Any] = {}
352
+ for key, member in value_dict.items():
353
+ name = str(key)
354
+ budget.consume(name, depth + 1)
355
+ result[name] = _convert_powerfx_value(member, budget, depth + 1)
356
+ return result
252
357
 
253
- # For other objects, try to convert to string representation
254
- return str(value)
358
+ return [_convert_powerfx_value(item, budget, depth + 1) for item in cast(list[Any], value)]
255
359
 
256
360
 
257
361
  class DeclarativeWorkflowState:
@@ -291,6 +395,7 @@ class DeclarativeWorkflowState:
291
395
  Args:
292
396
  inputs: Initial workflow inputs (become Workflow.Inputs.*)
293
397
  """
398
+ _validate_powerfx_state(inputs)
294
399
  conversation_id = str(uuid.uuid4())
295
400
  state_data: DeclarativeStateData = {
296
401
  "Inputs": dict(inputs) if inputs else {},
@@ -309,10 +414,11 @@ class DeclarativeWorkflowState:
309
414
  "Conversation": {"messages": [], "history": []},
310
415
  "Custom": {},
311
416
  }
312
- self._state.set(DECLARATIVE_STATE_KEY, state_data)
417
+ self.set_state_data(state_data)
313
418
 
314
419
  def get_state_data(self) -> DeclarativeStateData:
315
420
  """Get the full state data dict from state."""
421
+ self._state._validate(DECLARATIVE_STATE_KEY, _validate_powerfx_state) # pyright: ignore[reportPrivateUsage]
316
422
  result = self._state.get(DECLARATIVE_STATE_KEY)
317
423
  if result is None:
318
424
  # Initialize if not present
@@ -328,10 +434,12 @@ class DeclarativeWorkflowState:
328
434
  scenarios), the start executor needs to avoid calling initialize()
329
435
  and clobbering the prior turn's Conversation/Local/System data.
330
436
  """
437
+ self._state._validate(DECLARATIVE_STATE_KEY, _validate_powerfx_state) # pyright: ignore[reportPrivateUsage]
331
438
  return self._state.get(DECLARATIVE_STATE_KEY) is not None
332
439
 
333
440
  def set_state_data(self, data: DeclarativeStateData) -> None:
334
441
  """Set the full state data dict in state."""
442
+ _validate_powerfx_state(data)
335
443
  self._state.set(DECLARATIVE_STATE_KEY, data)
336
444
 
337
445
  def get(self, path: str, default: Any = None) -> Any:
@@ -390,7 +498,7 @@ class DeclarativeWorkflowState:
390
498
  return default
391
499
  else:
392
500
  # Attribute access is only allowed for safe declarative identifiers.
393
- if not _SAFE_PATH_SEGMENT_RE.match(part):
501
+ if not _is_safe_path_segment(part):
394
502
  logger.warning(
395
503
  "DeclarativeWorkflowState.get: rejecting attribute segment %r in path %r",
396
504
  part,
@@ -524,6 +632,8 @@ class DeclarativeWorkflowState:
524
632
  Raises:
525
633
  RuntimeError: If the powerfx package is not installed and the
526
634
  expression requires PowerFx evaluation.
635
+ ValueError: If state copying or symbol conversion exceeds the
636
+ PowerFx state budget.
527
637
  """
528
638
  if not expression:
529
639
  return expression
@@ -667,11 +777,11 @@ class DeclarativeWorkflowState:
667
777
  When they appear nested inside other functions (e.g., Upper(MessageText(...))),
668
778
  we need to evaluate them first and replace with the result.
669
779
 
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.
780
+ Results are stored in temporary state variables so untrusted message text is
781
+ passed to PowerFx as data rather than inserted into formula source.
782
+ Temporary names avoid existing Local keys and references in the original formula.
783
+ Literal text, quoted identifiers, and comments are left untouched; expression
784
+ sections inside PowerFx interpolated strings are preprocessed as code.
675
785
 
676
786
  Args:
677
787
  formula: The PowerFx formula to pre-process
@@ -684,90 +794,53 @@ class DeclarativeWorkflowState:
684
794
  Returns:
685
795
  The rewritten formula.
686
796
  """
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
797
  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:
798
+ reserved_names = {name.casefold() for name in self.get_state_data().get("Local", {})}
799
+ # Reserve formula references too, so previously undefined names stay undefined.
800
+ folded_formula = formula.casefold()
801
+ function_name = "MessageText"
802
+ call_prefix = f"{function_name}("
803
+ result: list[str] = []
804
+ copied_until = 0
805
+ positions = _iter_powerfx_expression_indices(formula)
806
+
807
+ for cursor in positions:
808
+ if not formula.startswith(call_prefix, cursor):
809
+ continue
810
+
811
+ paren_start = cursor + len(function_name)
812
+ depth = 1
813
+ for pos in positions:
814
+ if pos <= paren_start:
815
+ continue
816
+ char = formula[pos]
817
+ if char == "(":
818
+ depth += 1
819
+ elif char == ")":
820
+ depth -= 1
821
+ if depth == 0:
706
822
  break
823
+ else:
824
+ break
707
825
 
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
826
+ inner_expr = formula[paren_start + 1 : pos]
827
+ replacement = self._eval_and_replace_message_text(inner_expr)
828
+ while True:
829
+ temp_var_name = f"_TempMessageText{temp_var_counter}"
830
+ temp_var_counter += 1
831
+ folded_name = temp_var_name.casefold()
832
+ if folded_name not in reserved_names and folded_name not in folded_formula:
737
833
  break
834
+ temp_var_path = f"Local.{temp_var_name}"
835
+ temp_writes.append((temp_var_path, self.get(temp_var_path, default=self._MISSING)))
836
+ self.set(temp_var_path, replacement)
837
+ result.append(formula[copied_until:cursor])
838
+ result.append(temp_var_path)
839
+ logger.debug(f"Stored MessageText result ({len(replacement)} chars) in temp variable {temp_var_name}")
840
+ copied_until = pos + 1
738
841
 
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
842
+ result.append(formula[copied_until:])
843
+ return "".join(result)
771
844
 
772
845
  def _eval_and_replace_message_text(self, inner_expr: str) -> str:
773
846
  """Evaluate MessageText() and return the text result.
@@ -884,10 +957,10 @@ class DeclarativeWorkflowState:
884
957
  symbols["Env"] = env_bound
885
958
  # Debug log the Local symbols to help diagnose type issues
886
959
  if local_data:
887
- for key, value in local_data.items():
960
+ for value in local_data.values():
888
961
  logger.debug(
889
- f"PowerFx symbol Local.{key}: type={type(value).__name__}, "
890
- f"value_preview={str(value)[:100] if value else None}"
962
+ "PowerFx Local symbol type=%s",
963
+ type(value).__name__,
891
964
  )
892
965
  result = _make_powerfx_safe(symbols)
893
966
  return cast(dict[str, Any], result)