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.
- agent_framework_declarative-1.2.0/PKG-INFO +90 -0
- agent_framework_declarative-1.2.0/README.md +63 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_loader.py +6 -4
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_declarative_base.py +189 -116
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_executors_agents.py +73 -51
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_executors_basic.py +8 -5
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_executors_http.py +5 -33
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_executors_mcp.py +81 -27
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_executors_tools.py +17 -8
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_factory.py +7 -4
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_http_handler.py +46 -43
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_mcp_handler.py +142 -51
- agent_framework_declarative-1.2.0/agent_framework_declarative/_workflows/_powerfx_limits.py +76 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_state.py +49 -9
- agent_framework_declarative-1.2.0/agent_framework_declarative/_workflows/_state_path.py +12 -0
- agent_framework_declarative-1.2.0/agent_framework_declarative/py.typed +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/pyproject.toml +3 -3
- agent_framework_declarative-1.0.4/PKG-INFO +0 -49
- agent_framework_declarative-1.0.4/README.md +0 -22
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/LICENSE +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/__init__.py +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_feature_usage.py +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_models.py +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/__init__.py +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_declarative_builder.py +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_errors.py +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_executors_control_flow.py +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_executors_external_input.py +0 -0
- {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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
if isinstance(value, Mapping):
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
671
|
-
to
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
#
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
740
|
-
|
|
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
|
|
960
|
+
for value in local_data.values():
|
|
888
961
|
logger.debug(
|
|
889
|
-
|
|
890
|
-
|
|
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)
|