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.
- agent_framework_declarative/__init__.py +71 -0
- agent_framework_declarative/_loader.py +868 -0
- agent_framework_declarative/_models.py +1154 -0
- agent_framework_declarative/_workflows/__init__.py +167 -0
- agent_framework_declarative/_workflows/_declarative_base.py +1226 -0
- agent_framework_declarative/_workflows/_declarative_builder.py +1057 -0
- agent_framework_declarative/_workflows/_errors.py +38 -0
- agent_framework_declarative/_workflows/_executors_agents.py +1025 -0
- agent_framework_declarative/_workflows/_executors_basic.py +574 -0
- agent_framework_declarative/_workflows/_executors_control_flow.py +461 -0
- agent_framework_declarative/_workflows/_executors_external_input.py +243 -0
- agent_framework_declarative/_workflows/_executors_http.py +417 -0
- agent_framework_declarative/_workflows/_executors_mcp.py +549 -0
- agent_framework_declarative/_workflows/_executors_tools.py +660 -0
- agent_framework_declarative/_workflows/_factory.py +808 -0
- agent_framework_declarative/_workflows/_http_handler.py +237 -0
- agent_framework_declarative/_workflows/_mcp_handler.py +581 -0
- agent_framework_declarative/_workflows/_powerfx_functions.py +498 -0
- agent_framework_declarative/_workflows/_state.py +650 -0
- agent_framework_declarative-1.0.0.dist-info/METADATA +49 -0
- agent_framework_declarative-1.0.0.dist-info/RECORD +23 -0
- agent_framework_declarative-1.0.0.dist-info/WHEEL +4 -0
- agent_framework_declarative-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|