agent-framework-declarative 1.1.0__tar.gz → 1.2.1__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.1.0 → agent_framework_declarative-1.2.1}/PKG-INFO +2 -2
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/agent_framework_declarative/_workflows/_declarative_base.py +75 -32
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/agent_framework_declarative/_workflows/_executors_agents.py +74 -64
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/agent_framework_declarative/_workflows/_executors_basic.py +8 -5
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/agent_framework_declarative/_workflows/_executors_mcp.py +81 -27
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/agent_framework_declarative/_workflows/_executors_tools.py +17 -8
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/agent_framework_declarative/_workflows/_factory.py +6 -3
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/agent_framework_declarative/_workflows/_http_handler.py +33 -39
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/agent_framework_declarative/_workflows/_powerfx_functions.py +3 -1
- agent_framework_declarative-1.2.1/agent_framework_declarative/_workflows/_powerfx_limits.py +76 -0
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/agent_framework_declarative/_workflows/_state.py +49 -9
- agent_framework_declarative-1.2.1/agent_framework_declarative/_workflows/_state_path.py +12 -0
- agent_framework_declarative-1.2.1/agent_framework_declarative/py.typed +0 -0
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/pyproject.toml +2 -2
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/LICENSE +0 -0
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/README.md +0 -0
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/agent_framework_declarative/__init__.py +0 -0
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/agent_framework_declarative/_feature_usage.py +0 -0
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/agent_framework_declarative/_loader.py +0 -0
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/agent_framework_declarative/_models.py +0 -0
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/agent_framework_declarative/_workflows/__init__.py +0 -0
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/agent_framework_declarative/_workflows/_declarative_builder.py +0 -0
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/agent_framework_declarative/_workflows/_errors.py +0 -0
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/agent_framework_declarative/_workflows/_executors_control_flow.py +0 -0
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/agent_framework_declarative/_workflows/_executors_external_input.py +0 -0
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/agent_framework_declarative/_workflows/_executors_http.py +0 -0
- {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.1}/agent_framework_declarative/_workflows/_mcp_handler.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-framework-declarative
|
|
3
|
-
Version: 1.1
|
|
3
|
+
Version: 1.2.1
|
|
4
4
|
Summary: Declarative specification support for Microsoft Agent Framework.
|
|
5
5
|
Author-email: Microsoft <af-support@microsoft.com>
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
16
|
Classifier: Typing :: Typed
|
|
17
17
|
License-File: LICENSE
|
|
18
|
-
Requires-Dist: agent-framework-core>=1.
|
|
18
|
+
Requires-Dist: agent-framework-core>=1.20.0,<2
|
|
19
19
|
Requires-Dist: httpx>=0.27,<1
|
|
20
20
|
Requires-Dist: powerfx>=0.0.32,<0.0.35; python_version < '3.14'
|
|
21
21
|
Requires-Dist: pyyaml>=6.0,<7.0
|
|
@@ -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,9 +67,6 @@ 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_]*$")
|
|
68
|
-
|
|
69
70
|
|
|
70
71
|
def _skip_powerfx_opaque_token(formula: str, start: int) -> int:
|
|
71
72
|
"""Return the end of an ordinary quoted token or comment, or start if neither."""
|
|
@@ -167,6 +168,8 @@ class DeclarativeEnvConfig:
|
|
|
167
168
|
# contents of ``values`` / ``referenced_names``: caller mutations
|
|
168
169
|
# to the original objects after construction cannot leak into
|
|
169
170
|
# ``resolve()``.
|
|
171
|
+
_validate_powerfx_state(self.values)
|
|
172
|
+
_validate_powerfx_state(self.referenced_names)
|
|
170
173
|
object.__setattr__(self, "values", MappingProxyType(dict(self.values)))
|
|
171
174
|
object.__setattr__(self, "referenced_names", frozenset(self.referenced_names))
|
|
172
175
|
|
|
@@ -179,6 +182,8 @@ class DeclarativeEnvConfig:
|
|
|
179
182
|
unrelated environment variables never enter the PowerFx scope.
|
|
180
183
|
Configuration values always win over the environment fallback.
|
|
181
184
|
"""
|
|
185
|
+
_validate_powerfx_state(self.values)
|
|
186
|
+
_validate_powerfx_state(self.referenced_names)
|
|
182
187
|
resolved = {name: str(value) for name, value in self.values.items()}
|
|
183
188
|
if self.restrict_to_configuration:
|
|
184
189
|
return resolved
|
|
@@ -197,6 +202,10 @@ def discover_env_references(node: Any) -> set[str]:
|
|
|
197
202
|
happen to mention ``Env.SOMETHING`` as plain text, the scan only inspects
|
|
198
203
|
strings that begin with ``=`` (PowerFx expression marker, matching the
|
|
199
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.
|
|
200
209
|
|
|
201
210
|
Args:
|
|
202
211
|
node: A parsed workflow definition (typically the dict produced by
|
|
@@ -205,23 +214,43 @@ def discover_env_references(node: Any) -> set[str]:
|
|
|
205
214
|
Returns:
|
|
206
215
|
The set of ``Env`` identifier names referenced in PowerFx
|
|
207
216
|
expressions inside ``node``.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
DeclarativeWorkflowError: If the definition contains a mapping/list cycle.
|
|
208
220
|
"""
|
|
209
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,)))]
|
|
210
226
|
|
|
211
|
-
|
|
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
|
|
212
236
|
if isinstance(value, str):
|
|
213
237
|
if value.startswith("="):
|
|
214
238
|
names.update(_ENV_REFERENCE_RE.findall(value))
|
|
215
|
-
|
|
216
|
-
if isinstance(value, Mapping):
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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))
|
|
223
253
|
|
|
224
|
-
visit(node)
|
|
225
254
|
return names
|
|
226
255
|
|
|
227
256
|
|
|
@@ -288,10 +317,16 @@ def _make_powerfx_safe(value: Any) -> Any:
|
|
|
288
317
|
|
|
289
318
|
Returns:
|
|
290
319
|
A PowerFx-safe representation of the value
|
|
320
|
+
|
|
321
|
+
Raises:
|
|
322
|
+
ValueError: If the input or converted symbols exceed the state budget.
|
|
291
323
|
"""
|
|
292
|
-
|
|
293
|
-
|
|
324
|
+
_validate_powerfx_state(value)
|
|
325
|
+
return _convert_powerfx_value(value, _PowerFxStateBudget(), 0)
|
|
326
|
+
|
|
294
327
|
|
|
328
|
+
def _convert_powerfx_value(value: Any, budget: _PowerFxStateBudget, depth: int) -> Any:
|
|
329
|
+
"""Convert an already bounded value without restarting its traversal budget."""
|
|
295
330
|
# Enum coercion must run BEFORE the primitive type check: many MAF
|
|
296
331
|
# enums (e.g. MessageRole) are ``str``-subclass enums, so they pass
|
|
297
332
|
# ``isinstance(v, str)`` but pythonnet refuses to convert them to
|
|
@@ -300,25 +335,27 @@ def _make_powerfx_safe(value: Any) -> Any:
|
|
|
300
335
|
# to the underlying value (or its string form) so PowerFx sees a
|
|
301
336
|
# plain ``str``/``int``.
|
|
302
337
|
if isinstance(value, Enum):
|
|
303
|
-
return
|
|
338
|
+
return _convert_powerfx_value(value.value, budget, depth)
|
|
304
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)
|
|
305
346
|
if isinstance(value, _POWERFX_SAFE_TYPES):
|
|
306
347
|
return value
|
|
307
348
|
|
|
308
349
|
if isinstance(value, dict):
|
|
309
350
|
value_dict = cast(Mapping[Any, Any], value)
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
# Try to convert objects with __dict__ or dataclass-style attributes
|
|
317
|
-
if hasattr(value, "__dict__"):
|
|
318
|
-
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
|
|
319
357
|
|
|
320
|
-
|
|
321
|
-
return str(value)
|
|
358
|
+
return [_convert_powerfx_value(item, budget, depth + 1) for item in cast(list[Any], value)]
|
|
322
359
|
|
|
323
360
|
|
|
324
361
|
class DeclarativeWorkflowState:
|
|
@@ -358,6 +395,7 @@ class DeclarativeWorkflowState:
|
|
|
358
395
|
Args:
|
|
359
396
|
inputs: Initial workflow inputs (become Workflow.Inputs.*)
|
|
360
397
|
"""
|
|
398
|
+
_validate_powerfx_state(inputs)
|
|
361
399
|
conversation_id = str(uuid.uuid4())
|
|
362
400
|
state_data: DeclarativeStateData = {
|
|
363
401
|
"Inputs": dict(inputs) if inputs else {},
|
|
@@ -376,10 +414,11 @@ class DeclarativeWorkflowState:
|
|
|
376
414
|
"Conversation": {"messages": [], "history": []},
|
|
377
415
|
"Custom": {},
|
|
378
416
|
}
|
|
379
|
-
self.
|
|
417
|
+
self.set_state_data(state_data)
|
|
380
418
|
|
|
381
419
|
def get_state_data(self) -> DeclarativeStateData:
|
|
382
420
|
"""Get the full state data dict from state."""
|
|
421
|
+
self._state._validate(DECLARATIVE_STATE_KEY, _validate_powerfx_state) # pyright: ignore[reportPrivateUsage]
|
|
383
422
|
result = self._state.get(DECLARATIVE_STATE_KEY)
|
|
384
423
|
if result is None:
|
|
385
424
|
# Initialize if not present
|
|
@@ -395,10 +434,12 @@ class DeclarativeWorkflowState:
|
|
|
395
434
|
scenarios), the start executor needs to avoid calling initialize()
|
|
396
435
|
and clobbering the prior turn's Conversation/Local/System data.
|
|
397
436
|
"""
|
|
437
|
+
self._state._validate(DECLARATIVE_STATE_KEY, _validate_powerfx_state) # pyright: ignore[reportPrivateUsage]
|
|
398
438
|
return self._state.get(DECLARATIVE_STATE_KEY) is not None
|
|
399
439
|
|
|
400
440
|
def set_state_data(self, data: DeclarativeStateData) -> None:
|
|
401
441
|
"""Set the full state data dict in state."""
|
|
442
|
+
_validate_powerfx_state(data)
|
|
402
443
|
self._state.set(DECLARATIVE_STATE_KEY, data)
|
|
403
444
|
|
|
404
445
|
def get(self, path: str, default: Any = None) -> Any:
|
|
@@ -457,7 +498,7 @@ class DeclarativeWorkflowState:
|
|
|
457
498
|
return default
|
|
458
499
|
else:
|
|
459
500
|
# Attribute access is only allowed for safe declarative identifiers.
|
|
460
|
-
if not
|
|
501
|
+
if not _is_safe_path_segment(part):
|
|
461
502
|
logger.warning(
|
|
462
503
|
"DeclarativeWorkflowState.get: rejecting attribute segment %r in path %r",
|
|
463
504
|
part,
|
|
@@ -591,6 +632,8 @@ class DeclarativeWorkflowState:
|
|
|
591
632
|
Raises:
|
|
592
633
|
RuntimeError: If the powerfx package is not installed and the
|
|
593
634
|
expression requires PowerFx evaluation.
|
|
635
|
+
ValueError: If state copying or symbol conversion exceeds the
|
|
636
|
+
PowerFx state budget.
|
|
594
637
|
"""
|
|
595
638
|
if not expression:
|
|
596
639
|
return expression
|
|
@@ -914,10 +957,10 @@ class DeclarativeWorkflowState:
|
|
|
914
957
|
symbols["Env"] = env_bound
|
|
915
958
|
# Debug log the Local symbols to help diagnose type issues
|
|
916
959
|
if local_data:
|
|
917
|
-
for
|
|
960
|
+
for value in local_data.values():
|
|
918
961
|
logger.debug(
|
|
919
|
-
|
|
920
|
-
|
|
962
|
+
"PowerFx Local symbol type=%s",
|
|
963
|
+
type(value).__name__,
|
|
921
964
|
)
|
|
922
965
|
result = _make_powerfx_safe(symbols)
|
|
923
966
|
return cast(dict[str, Any], result)
|
|
@@ -506,6 +506,13 @@ def _normalize_variable_path(variable: str) -> str:
|
|
|
506
506
|
class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
|
|
507
507
|
"""Executor that invokes a Microsoft Foundry agent.
|
|
508
508
|
|
|
509
|
+
``output.autoSend`` defaults to true and accepts a Boolean or a
|
|
510
|
+
``=``-prefixed PowerFx Boolean expression, such as ``=Local.publishResult``.
|
|
511
|
+
Expressions use current state before each invocation, including resumed
|
|
512
|
+
external-loop turns. False suppresses automatic output, not invocation,
|
|
513
|
+
result storage or conversation history; later actions can explicitly emit
|
|
514
|
+
the stored results.
|
|
515
|
+
|
|
509
516
|
This executor supports both Python-style and .NET-style YAML schemas:
|
|
510
517
|
|
|
511
518
|
Python-style (simple):
|
|
@@ -615,8 +622,8 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
|
|
|
615
622
|
|
|
616
623
|
return arguments, messages, external_loop_when, max_iterations
|
|
617
624
|
|
|
618
|
-
def _get_output_config(self) -> tuple[str | None, str | None, str | None, bool]:
|
|
619
|
-
"""Parse output
|
|
625
|
+
def _get_output_config(self, state: DeclarativeWorkflowState) -> tuple[str | None, str | None, str | None, bool]:
|
|
626
|
+
"""Parse output bindings and evaluate autoSend against the current state.
|
|
620
627
|
|
|
621
628
|
Returns:
|
|
622
629
|
Tuple of (messages var, responseObject var, resultProperty, autoSend)
|
|
@@ -637,7 +644,7 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
|
|
|
637
644
|
property_val: Any = output_dict.get("property")
|
|
638
645
|
property_var: str | None = str(property_val) if property_val is not None else None
|
|
639
646
|
auto_send_val: Any = output_dict.get("autoSend", True)
|
|
640
|
-
auto_send: bool = bool(auto_send_val)
|
|
647
|
+
auto_send: bool = bool(state.eval_if_expression(auto_send_val))
|
|
641
648
|
|
|
642
649
|
return messages_var, response_obj_var, property_var or result_property, auto_send
|
|
643
650
|
|
|
@@ -675,6 +682,12 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
|
|
|
675
682
|
async def _build_input_text(self, state: Any, arguments: dict[str, Any], messages_expr: Any) -> str:
|
|
676
683
|
"""Build input text from arguments and messages.
|
|
677
684
|
|
|
685
|
+
``input.arguments`` are formatted as ``key: value`` lines (same shape as the
|
|
686
|
+
multi-value ``Workflow.Inputs`` fallback) and included in the text sent to
|
|
687
|
+
``agent.run()``. Python's agent ``run()`` has no separate structured-inputs
|
|
688
|
+
channel, so arguments must be folded into this text rather than discarded
|
|
689
|
+
(#7902).
|
|
690
|
+
|
|
678
691
|
Args:
|
|
679
692
|
state: Workflow state for expression evaluation
|
|
680
693
|
arguments: Input arguments to evaluate
|
|
@@ -683,55 +696,61 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
|
|
|
683
696
|
Returns:
|
|
684
697
|
Input text for the agent
|
|
685
698
|
"""
|
|
686
|
-
# Evaluate arguments
|
|
687
699
|
evaluated_args: dict[str, Any] = {}
|
|
688
700
|
for key, value in arguments.items():
|
|
689
701
|
evaluated_args[key] = state.eval_if_expression(value)
|
|
702
|
+
args_text = "\n".join(f"{k}: {v}" for k, v in evaluated_args.items()) if evaluated_args else ""
|
|
690
703
|
|
|
691
|
-
|
|
704
|
+
messages_text = ""
|
|
692
705
|
if messages_expr:
|
|
693
706
|
evaluated_input: Any = state.eval_if_expression(messages_expr)
|
|
694
707
|
if isinstance(evaluated_input, str):
|
|
695
|
-
|
|
696
|
-
|
|
708
|
+
messages_text = evaluated_input
|
|
709
|
+
elif isinstance(evaluated_input, list) and evaluated_input:
|
|
697
710
|
# Extract text from last message
|
|
698
711
|
last: Any = evaluated_input[-1] # type: ignore
|
|
699
712
|
if isinstance(last, str):
|
|
700
|
-
|
|
701
|
-
|
|
713
|
+
messages_text = last
|
|
714
|
+
elif isinstance(last, dict):
|
|
702
715
|
last_dict = cast(dict[str, Any], last)
|
|
703
716
|
content_val: Any = last_dict.get("content", last_dict.get("text", ""))
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
#
|
|
718
|
-
|
|
719
|
-
if
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
if
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
if
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
717
|
+
messages_text = str(content_val) if content_val else ""
|
|
718
|
+
elif last is not None and hasattr(last, "text"): # type: ignore
|
|
719
|
+
messages_text = str(getattr(last, "text", "")) # type: ignore
|
|
720
|
+
elif evaluated_input:
|
|
721
|
+
messages_text = str(cast(Any, evaluated_input))
|
|
722
|
+
elif not evaluated_args:
|
|
723
|
+
# Fallback chain for implicit input (like .NET conversationId pattern):
|
|
724
|
+
# Only when neither explicit messages nor explicit arguments are present.
|
|
725
|
+
# Otherwise arguments-only actions (e.g. customer-support TicketingAgent)
|
|
726
|
+
# would append System.LastMessage (prior agent's response) or
|
|
727
|
+
# Workflow.Inputs to their structured fields in chained workflows.
|
|
728
|
+
# 1. Local.input / Local.userInput (explicit turn state)
|
|
729
|
+
# 2. System.LastMessage.Text (previous agent's response)
|
|
730
|
+
# 3. Workflow.Inputs (first agent gets workflow inputs)
|
|
731
|
+
messages_text = str(state.get("Local.input") or state.get("Local.userInput") or "")
|
|
732
|
+
if not messages_text:
|
|
733
|
+
# Try System.LastMessage.Text (used by external loop and agent chaining)
|
|
734
|
+
last_message: Any = state.get("System.LastMessage")
|
|
735
|
+
if isinstance(last_message, dict):
|
|
736
|
+
last_msg_dict = cast(dict[str, Any], last_message)
|
|
737
|
+
text_val: Any = last_msg_dict.get("Text", "")
|
|
738
|
+
messages_text = str(text_val) if text_val else ""
|
|
739
|
+
if not messages_text:
|
|
740
|
+
# Fall back to workflow inputs (for first agent in chain)
|
|
741
|
+
inputs: Any = state.get("Workflow.Inputs")
|
|
742
|
+
if isinstance(inputs, dict):
|
|
743
|
+
inputs_dict = cast(dict[str, Any], inputs)
|
|
744
|
+
# If single input, use its value directly
|
|
745
|
+
if len(inputs_dict) == 1:
|
|
746
|
+
messages_text = str(next(iter(inputs_dict.values())))
|
|
747
|
+
else:
|
|
748
|
+
# Multiple inputs - format as key: value pairs
|
|
749
|
+
messages_text = "\n".join(f"{k}: {v}" for k, v in inputs_dict.items())
|
|
750
|
+
|
|
751
|
+
if args_text and messages_text:
|
|
752
|
+
return f"{args_text}\n{messages_text}"
|
|
753
|
+
return args_text or messages_text or ""
|
|
735
754
|
|
|
736
755
|
def _get_agent(self, agent_name: str, ctx: WorkflowContext[Any, Any]) -> Any:
|
|
737
756
|
"""Get agent from registry (sync helper for response handler)."""
|
|
@@ -788,28 +807,16 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
|
|
|
788
807
|
_validate_conversation_history(messages_for_agent, agent_name)
|
|
789
808
|
|
|
790
809
|
# Retrieve kwargs passed to workflow.run() so they propagate to agent tools
|
|
791
|
-
from agent_framework._workflows import
|
|
792
|
-
from agent_framework._workflows import
|
|
793
|
-
|
|
794
|
-
run_kwargs
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
if not isinstance(resolved_run_kwargs, dict):
|
|
802
|
-
raise TypeError("Resolved workflow run kwargs state must be a dict.")
|
|
803
|
-
run_kwargs = cast(Any, prepare_run_kwargs)(self.id, run_kwargs, resolved_run_kwargs)
|
|
804
|
-
options: dict[str, Any] | None = None
|
|
805
|
-
if run_kwargs:
|
|
806
|
-
# Merge caller-provided options to avoid duplicate keyword argument
|
|
807
|
-
options = dict(run_kwargs.get("options") or {})
|
|
808
|
-
options["additional_function_arguments"] = run_kwargs
|
|
809
|
-
# Exclude 'options' from splat to avoid TypeError on duplicate keyword,
|
|
810
|
-
# and keep internal workflow-routing copies (stored under underscore
|
|
811
|
-
# keys for nested executors) out of the public Agent.run signature
|
|
812
|
-
run_kwargs = {k: v for k, v in run_kwargs.items() if k != "options" and not k.startswith("_")}
|
|
810
|
+
from agent_framework._workflows._agent_utils import prepare_executor_run_kwargs
|
|
811
|
+
from agent_framework._workflows._const import RESOLVED_WORKFLOW_RUN_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY
|
|
812
|
+
|
|
813
|
+
run_kwargs = prepare_executor_run_kwargs(
|
|
814
|
+
self.id,
|
|
815
|
+
ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {}),
|
|
816
|
+
ctx.get_state(RESOLVED_WORKFLOW_RUN_KWARGS_KEY),
|
|
817
|
+
)
|
|
818
|
+
options = run_kwargs.pop("options", None)
|
|
819
|
+
run_kwargs = {k: v for k, v in run_kwargs.items() if not k.startswith("_")}
|
|
813
820
|
|
|
814
821
|
# Use run() method to get properly structured messages (including tool calls and results)
|
|
815
822
|
# This is critical for multi-turn conversations where tool calls must be followed
|
|
@@ -925,7 +932,7 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
|
|
|
925
932
|
logger.debug("handle_action: starting agent '%s'", agent_name)
|
|
926
933
|
|
|
927
934
|
arguments, messages_expr, external_loop_when, max_iterations = self._get_input_config()
|
|
928
|
-
messages_var, response_obj_var, result_property, auto_send = self._get_output_config()
|
|
935
|
+
messages_var, response_obj_var, result_property, auto_send = self._get_output_config(state)
|
|
929
936
|
|
|
930
937
|
# Get conversation-specific messages path if conversationId is specified
|
|
931
938
|
conversation_id_expr = self._get_conversation_id()
|
|
@@ -1095,6 +1102,9 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
|
|
|
1095
1102
|
f"Agent '{agent_name}' invocation failed: not found during loop resumption"
|
|
1096
1103
|
)
|
|
1097
1104
|
|
|
1105
|
+
_, _, _, auto_send = self._get_output_config(state)
|
|
1106
|
+
loop_state.auto_send = auto_send
|
|
1107
|
+
|
|
1098
1108
|
try:
|
|
1099
1109
|
accumulated_response, all_messages, tool_calls = await self._invoke_agent_and_store_results(
|
|
1100
1110
|
agent=agent,
|
|
@@ -1105,7 +1115,7 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
|
|
|
1105
1115
|
messages_var=loop_state.messages_var,
|
|
1106
1116
|
response_obj_var=loop_state.response_obj_var,
|
|
1107
1117
|
result_property=loop_state.result_property,
|
|
1108
|
-
auto_send=
|
|
1118
|
+
auto_send=auto_send,
|
|
1109
1119
|
messages_path=loop_state.messages_path,
|
|
1110
1120
|
)
|
|
1111
1121
|
except (AgentInvalidRequestException, AgentInvalidResponseException):
|
|
@@ -224,6 +224,13 @@ class SendActivityExecutor(DeclarativeActionExecutor):
|
|
|
224
224
|
"""Executor for the SendActivity action.
|
|
225
225
|
|
|
226
226
|
Sends a text message or activity as workflow output.
|
|
227
|
+
Authored text starting with ``=`` is evaluated and its result is emitted as data.
|
|
228
|
+
Other authored text supports ``{Variable.Path}`` template interpolation.
|
|
229
|
+
|
|
230
|
+
Expression results are not interpolated again. To migrate text that relied on
|
|
231
|
+
a second pass, author the template directly (``Hello, {Local.name}!``) or build
|
|
232
|
+
the final text in the expression (``="Hello, " & Local.name & "!"``).
|
|
233
|
+
Both forms work as a string activity or as a mapping's ``text`` field.
|
|
227
234
|
"""
|
|
228
235
|
|
|
229
236
|
@handler
|
|
@@ -244,11 +251,7 @@ class SendActivityExecutor(DeclarativeActionExecutor):
|
|
|
244
251
|
text = activity
|
|
245
252
|
|
|
246
253
|
if isinstance(text, str):
|
|
247
|
-
|
|
248
|
-
text = state.eval_if_expression(text)
|
|
249
|
-
# Then interpolate any {Variable.Path} template syntax
|
|
250
|
-
if isinstance(text, str):
|
|
251
|
-
text = state.interpolate_string(text)
|
|
254
|
+
text = state.eval_if_expression(text) if text.startswith("=") else state.interpolate_string(text)
|
|
252
255
|
|
|
253
256
|
# Yield the text as workflow output
|
|
254
257
|
if text:
|
|
@@ -13,16 +13,20 @@ Security notes:
|
|
|
13
13
|
- Approval requests surface header NAMES only; header values are not echoed,
|
|
14
14
|
matching the posture of :mod:`._executors_http`.
|
|
15
15
|
- :class:`MCPToolApprovalRequest` carries the values the resume handler will
|
|
16
|
-
use; header values are re-evaluated
|
|
17
|
-
|
|
16
|
+
use; header values are re-evaluated and checked against a workflow-local
|
|
17
|
+
keyed binding. Changed or unverifiable headers require fresh approval.
|
|
18
|
+
The key stays in trusted host checkpoint state, never in approval payloads;
|
|
19
|
+
header values are not checkpointed.
|
|
18
20
|
- Tool outputs flow back into agent conversations through ``conversationId``
|
|
19
21
|
and through Tool-role messages emitted to ``output.messages``. They share
|
|
20
22
|
the same prompt-injection risk surface as ``HttpRequestAction``: workflow
|
|
21
23
|
authors must trust the MCP server they invoke.
|
|
22
24
|
"""
|
|
23
25
|
|
|
26
|
+
import hmac
|
|
24
27
|
import json
|
|
25
28
|
import logging
|
|
29
|
+
import secrets
|
|
26
30
|
import uuid
|
|
27
31
|
from collections.abc import Mapping
|
|
28
32
|
from dataclasses import dataclass, field
|
|
@@ -53,6 +57,7 @@ __all__ = [
|
|
|
53
57
|
]
|
|
54
58
|
|
|
55
59
|
logger = logging.getLogger(__name__)
|
|
60
|
+
_HEADER_BINDING_KEY = "_declarative_mcp_header_binding_key"
|
|
56
61
|
|
|
57
62
|
|
|
58
63
|
# ---------------------------------------------------------------------------
|
|
@@ -74,6 +79,8 @@ class MCPToolApprovalRequest:
|
|
|
74
79
|
connection_name: Connection identifier the invocation will use.
|
|
75
80
|
metadata: Internal routing data pinned at approval-request time
|
|
76
81
|
(e.g. ``conversation_id``) for use by the resume handler.
|
|
82
|
+
header_binding: Opaque binding of the reviewed headers. The verification
|
|
83
|
+
key is retained separately in trusted workflow state.
|
|
77
84
|
"""
|
|
78
85
|
|
|
79
86
|
request_id: str
|
|
@@ -84,6 +91,7 @@ class MCPToolApprovalRequest:
|
|
|
84
91
|
header_names: list[str] = field(default_factory=lambda: [])
|
|
85
92
|
connection_name: str | None = None
|
|
86
93
|
metadata: dict[str, Any] = field(default_factory=lambda: {})
|
|
94
|
+
header_binding: str | None = None
|
|
87
95
|
|
|
88
96
|
|
|
89
97
|
# ---------------------------------------------------------------------------
|
|
@@ -156,7 +164,8 @@ class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
|
|
|
156
164
|
- When ``requireApproval=true``: emits a :class:`MCPToolApprovalRequest`
|
|
157
165
|
via ``ctx.request_info()`` and yields. On resume, the response is
|
|
158
166
|
checked; on rejection, ``output.result`` is set to ``"Error: ..."`` and
|
|
159
|
-
no tool call is made.
|
|
167
|
+
no tool call is made. Changed headers, legacy unbound headers, or a
|
|
168
|
+
missing verification state require another approval.
|
|
160
169
|
- On success: parses each :class:`agent_framework.Content` output (text →
|
|
161
170
|
JSON-first / data / uri → URI string) and assigns the parsed list to
|
|
162
171
|
``output.result``. Builds a single Tool-role :class:`Message`
|
|
@@ -220,29 +229,6 @@ class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
|
|
|
220
229
|
output_messages_path = _get_output_path(self._action_def, "messages")
|
|
221
230
|
output_result_path = _get_output_path(self._action_def, "result")
|
|
222
231
|
|
|
223
|
-
if require_approval:
|
|
224
|
-
request_id = str(uuid.uuid4())
|
|
225
|
-
conversation_id = _evaluate_conversation_id(state, conversation_id_expr)
|
|
226
|
-
request = MCPToolApprovalRequest(
|
|
227
|
-
request_id=request_id,
|
|
228
|
-
tool_name=tool_name,
|
|
229
|
-
server_url=server_url,
|
|
230
|
-
server_label=server_label,
|
|
231
|
-
arguments=arguments,
|
|
232
|
-
header_names=sorted(headers.keys()),
|
|
233
|
-
connection_name=connection_name,
|
|
234
|
-
metadata={"conversation_id": conversation_id},
|
|
235
|
-
)
|
|
236
|
-
logger.info(
|
|
237
|
-
"%s: requesting approval for MCP tool '%s' on '%s'",
|
|
238
|
-
self.__class__.__name__,
|
|
239
|
-
tool_name,
|
|
240
|
-
server_url,
|
|
241
|
-
)
|
|
242
|
-
await ctx.request_info(request, ToolApprovalResponse, request_id=request_id)
|
|
243
|
-
return
|
|
244
|
-
|
|
245
|
-
# No approval required - invoke directly.
|
|
246
232
|
invocation = MCPToolInvocation(
|
|
247
233
|
server_url=server_url,
|
|
248
234
|
tool_name=tool_name,
|
|
@@ -251,6 +237,12 @@ class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
|
|
|
251
237
|
headers=headers,
|
|
252
238
|
connection_name=connection_name,
|
|
253
239
|
)
|
|
240
|
+
if require_approval:
|
|
241
|
+
await self._request_approval(
|
|
242
|
+
invocation, {"conversation_id": _evaluate_conversation_id(state, conversation_id_expr)}, ctx
|
|
243
|
+
)
|
|
244
|
+
return
|
|
245
|
+
|
|
254
246
|
result = await self._invoke_with_narrow_catch(invocation)
|
|
255
247
|
await self._process_result(
|
|
256
248
|
ctx=ctx,
|
|
@@ -265,6 +257,54 @@ class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
|
|
|
265
257
|
|
|
266
258
|
# ----- Approval response handler ------------------------------------------
|
|
267
259
|
|
|
260
|
+
def _bind_headers(
|
|
261
|
+
self,
|
|
262
|
+
ctx: WorkflowContext[ActionComplete, str],
|
|
263
|
+
request_id: str,
|
|
264
|
+
headers: Mapping[str, str],
|
|
265
|
+
*,
|
|
266
|
+
create_key: bool = False,
|
|
267
|
+
) -> str | None:
|
|
268
|
+
key = ctx.state.get(_HEADER_BINDING_KEY, None)
|
|
269
|
+
if key is None:
|
|
270
|
+
if not create_key:
|
|
271
|
+
return None
|
|
272
|
+
key = secrets.token_hex(32)
|
|
273
|
+
ctx.state.set(_HEADER_BINDING_KEY, key)
|
|
274
|
+
if not isinstance(key, str) or len(key) != 64 or any(char not in "0123456789abcdef" for char in key):
|
|
275
|
+
raise ValueError("Invalid MCP approval header binding state.")
|
|
276
|
+
# Stable sorting preserves the order of duplicate case-insensitive names.
|
|
277
|
+
canonical_headers = sorted(((name.lower(), value) for name, value in headers.items()), key=lambda item: item[0])
|
|
278
|
+
payload = json.dumps(
|
|
279
|
+
[request_id, canonical_headers],
|
|
280
|
+
ensure_ascii=True,
|
|
281
|
+
separators=(",", ":"),
|
|
282
|
+
).encode()
|
|
283
|
+
return hmac.digest(key.encode(), payload, "sha256").hex()
|
|
284
|
+
|
|
285
|
+
async def _request_approval(
|
|
286
|
+
self,
|
|
287
|
+
invocation: MCPToolInvocation,
|
|
288
|
+
metadata: dict[str, Any],
|
|
289
|
+
ctx: WorkflowContext[ActionComplete, str],
|
|
290
|
+
) -> None:
|
|
291
|
+
request_id = str(uuid.uuid4())
|
|
292
|
+
request = MCPToolApprovalRequest(
|
|
293
|
+
request_id=request_id,
|
|
294
|
+
tool_name=invocation.tool_name,
|
|
295
|
+
server_url=invocation.server_url,
|
|
296
|
+
server_label=invocation.server_label,
|
|
297
|
+
arguments=invocation.arguments,
|
|
298
|
+
header_names=sorted(invocation.headers),
|
|
299
|
+
connection_name=invocation.connection_name,
|
|
300
|
+
metadata=metadata,
|
|
301
|
+
header_binding=(
|
|
302
|
+
self._bind_headers(ctx, request_id, invocation.headers, create_key=True) if invocation.headers else None
|
|
303
|
+
),
|
|
304
|
+
)
|
|
305
|
+
logger.info("%s: requesting approval for MCP tool '%s'", self.__class__.__name__, invocation.tool_name)
|
|
306
|
+
await ctx.request_info(request, ToolApprovalResponse, request_id=request_id)
|
|
307
|
+
|
|
268
308
|
@response_handler
|
|
269
309
|
async def handle_approval_response(
|
|
270
310
|
self,
|
|
@@ -272,7 +312,7 @@ class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
|
|
|
272
312
|
response: ToolApprovalResponse,
|
|
273
313
|
ctx: WorkflowContext[ActionComplete, str],
|
|
274
314
|
) -> None:
|
|
275
|
-
"""Resume the
|
|
315
|
+
"""Resume the pinned operation only with the reviewed header context."""
|
|
276
316
|
state = self._get_state(ctx.state)
|
|
277
317
|
|
|
278
318
|
tool_name = original_request.tool_name
|
|
@@ -303,6 +343,20 @@ class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
|
|
|
303
343
|
headers=self._evaluate_headers(state, self._action_def.get("headers")),
|
|
304
344
|
connection_name=getattr(original_request, "connection_name", None),
|
|
305
345
|
)
|
|
346
|
+
if invocation.headers or original_request.header_names:
|
|
347
|
+
binding = getattr(original_request, "header_binding", None)
|
|
348
|
+
expected_binding = self._bind_headers(ctx, original_request.request_id, invocation.headers)
|
|
349
|
+
if (
|
|
350
|
+
not isinstance(binding, str)
|
|
351
|
+
or expected_binding is None
|
|
352
|
+
or not hmac.compare_digest(binding.encode(), expected_binding.encode())
|
|
353
|
+
):
|
|
354
|
+
logger.warning(
|
|
355
|
+
"%s: MCP header context changed or could not be verified; requesting fresh approval.",
|
|
356
|
+
self.__class__.__name__,
|
|
357
|
+
)
|
|
358
|
+
await self._request_approval(invocation, dict(metadata), ctx)
|
|
359
|
+
return
|
|
306
360
|
result = await self._invoke_with_narrow_catch(invocation)
|
|
307
361
|
await self._process_result(
|
|
308
362
|
ctx=ctx,
|
|
@@ -243,24 +243,23 @@ class BaseToolExecutor(DeclarativeActionExecutor):
|
|
|
243
243
|
|
|
244
244
|
return None
|
|
245
245
|
|
|
246
|
-
def _get_output_config(self) -> tuple[str | None, str | None,
|
|
247
|
-
"""Parse output
|
|
246
|
+
def _get_output_config(self) -> tuple[str | None, str | None, Any]:
|
|
247
|
+
"""Parse output bindings and the unevaluated autoSend setting.
|
|
248
248
|
|
|
249
249
|
Returns:
|
|
250
|
-
Tuple of (messages_var, result_var,
|
|
250
|
+
Tuple of (messages_var, result_var, auto_send_expr)
|
|
251
251
|
"""
|
|
252
|
-
output_config: dict[str,
|
|
252
|
+
output_config: dict[str, Any] = self._action_def.get("output", {})
|
|
253
253
|
|
|
254
254
|
if not isinstance(output_config, Mapping):
|
|
255
255
|
return None, None, True
|
|
256
256
|
|
|
257
257
|
messages_var = output_config.get("messages")
|
|
258
258
|
result_var = output_config.get("result")
|
|
259
|
-
auto_send = bool(output_config.get("autoSend", True))
|
|
260
259
|
return (
|
|
261
260
|
str(messages_var) if messages_var else None,
|
|
262
261
|
str(result_var) if result_var else None,
|
|
263
|
-
|
|
262
|
+
output_config.get("autoSend", True),
|
|
264
263
|
)
|
|
265
264
|
|
|
266
265
|
def _store_result(
|
|
@@ -443,7 +442,7 @@ class BaseToolExecutor(DeclarativeActionExecutor):
|
|
|
443
442
|
state = await self._ensure_state_initialized(ctx, trigger)
|
|
444
443
|
|
|
445
444
|
# Parse output configuration early so we can store errors
|
|
446
|
-
messages_var, result_var,
|
|
445
|
+
messages_var, result_var, auto_send_expr = self._get_output_config()
|
|
447
446
|
|
|
448
447
|
# Get and evaluate function name (required)
|
|
449
448
|
function_name_expr = self._action_def.get("functionName")
|
|
@@ -496,6 +495,7 @@ class BaseToolExecutor(DeclarativeActionExecutor):
|
|
|
496
495
|
return
|
|
497
496
|
|
|
498
497
|
# No approval required - invoke directly
|
|
498
|
+
auto_send = bool(state.eval_if_expression(auto_send_expr))
|
|
499
499
|
result = await self._execute_tool_invocation(
|
|
500
500
|
function_name=function_name,
|
|
501
501
|
arguments=arguments,
|
|
@@ -521,12 +521,14 @@ class BaseToolExecutor(DeclarativeActionExecutor):
|
|
|
521
521
|
``function_name`` and ``arguments`` are sourced from
|
|
522
522
|
``original_request`` (the payload the reviewer approved); output
|
|
523
523
|
configuration is re-derived from the executor's action definition.
|
|
524
|
+
Rejected calls store the rejection and complete without evaluating
|
|
525
|
+
``autoSend``.
|
|
524
526
|
"""
|
|
525
527
|
state = self._get_state(ctx.state)
|
|
526
528
|
|
|
527
529
|
function_name = original_request.function_name
|
|
528
530
|
arguments = original_request.arguments
|
|
529
|
-
messages_var, result_var,
|
|
531
|
+
messages_var, result_var, auto_send_expr = self._get_output_config()
|
|
530
532
|
|
|
531
533
|
# Check if approved
|
|
532
534
|
if response.approved is not True:
|
|
@@ -551,6 +553,7 @@ class BaseToolExecutor(DeclarativeActionExecutor):
|
|
|
551
553
|
return
|
|
552
554
|
|
|
553
555
|
# Approved - execute the invocation
|
|
556
|
+
auto_send = bool(state.eval_if_expression(auto_send_expr))
|
|
554
557
|
result = await self._execute_tool_invocation(
|
|
555
558
|
function_name=function_name,
|
|
556
559
|
arguments=arguments,
|
|
@@ -572,6 +575,12 @@ class BaseToolExecutor(DeclarativeActionExecutor):
|
|
|
572
575
|
class InvokeFunctionToolExecutor(BaseToolExecutor):
|
|
573
576
|
"""Executor that invokes a Python function as a tool.
|
|
574
577
|
|
|
578
|
+
``output.autoSend`` defaults to true and accepts a Boolean or a
|
|
579
|
+
``=``-prefixed PowerFx Boolean expression, such as ``=Local.publishResult``.
|
|
580
|
+
Expressions use current state immediately before direct or approved
|
|
581
|
+
invocation. False suppresses automatic output, not tool execution or result
|
|
582
|
+
storage; later actions can explicitly emit the stored results.
|
|
583
|
+
|
|
575
584
|
This executor supports invoking registered Python functions with:
|
|
576
585
|
- Expression evaluation for functionName and arguments
|
|
577
586
|
- Optional approval flow (yield/resume pattern)
|
|
@@ -210,7 +210,8 @@ class WorkflowFactory:
|
|
|
210
210
|
An executable Workflow object with action nodes for each YAML action.
|
|
211
211
|
|
|
212
212
|
Raises:
|
|
213
|
-
DeclarativeWorkflowError: If the YAML is invalid
|
|
213
|
+
DeclarativeWorkflowError: If the YAML is invalid, cannot be parsed, or
|
|
214
|
+
environment-reference discovery encounters a mapping/list cycle.
|
|
214
215
|
FileNotFoundError: If the YAML file doesn't exist.
|
|
215
216
|
|
|
216
217
|
Examples:
|
|
@@ -262,7 +263,8 @@ class WorkflowFactory:
|
|
|
262
263
|
An executable Workflow object with action nodes for each YAML action.
|
|
263
264
|
|
|
264
265
|
Raises:
|
|
265
|
-
DeclarativeWorkflowError: If the YAML is invalid
|
|
266
|
+
DeclarativeWorkflowError: If the YAML is invalid, cannot be parsed, or
|
|
267
|
+
environment-reference discovery encounters a mapping/list cycle.
|
|
266
268
|
|
|
267
269
|
Examples:
|
|
268
270
|
.. code-block:: python
|
|
@@ -338,7 +340,8 @@ class WorkflowFactory:
|
|
|
338
340
|
An executable Workflow object with action nodes for each YAML action.
|
|
339
341
|
|
|
340
342
|
Raises:
|
|
341
|
-
DeclarativeWorkflowError: If the definition is invalid
|
|
343
|
+
DeclarativeWorkflowError: If the definition is invalid, is missing required
|
|
344
|
+
fields, or environment-reference discovery encounters a mapping/list cycle.
|
|
342
345
|
|
|
343
346
|
Examples:
|
|
344
347
|
.. code-block:: python
|
|
@@ -22,10 +22,10 @@ from __future__ import annotations
|
|
|
22
22
|
|
|
23
23
|
import asyncio
|
|
24
24
|
from collections.abc import Awaitable, Callable, Mapping
|
|
25
|
-
from dataclasses import dataclass, field
|
|
25
|
+
from dataclasses import dataclass, field, replace
|
|
26
26
|
from http.cookiejar import CookieJar, DefaultCookiePolicy
|
|
27
27
|
from typing import Any, Protocol, runtime_checkable
|
|
28
|
-
from urllib.parse import parse_qsl, quote, urlencode, urlsplit
|
|
28
|
+
from urllib.parse import parse_qsl, quote, urlencode, urlsplit
|
|
29
29
|
|
|
30
30
|
import httpx
|
|
31
31
|
|
|
@@ -131,6 +131,12 @@ class DefaultHttpRequestHandler:
|
|
|
131
131
|
provider's clients retain their cookie behavior and are not closed by :meth:`aclose`.
|
|
132
132
|
Returning ``None`` falls back to ``client``, if supplied, then to the owned client.
|
|
133
133
|
|
|
134
|
+
The provider receives a new :class:`HttpRequestInfo` with an absolute HTTP(S) URL
|
|
135
|
+
normalized by ``httpx`` and ``query_parameters`` already appended to ``url``.
|
|
136
|
+
The snapshot's ``query_parameters`` is empty; the original request is not modified.
|
|
137
|
+
Client-level query defaults are applied after selection, and supplied clients retain
|
|
138
|
+
their redirect behavior. The provider is not called again for automatic redirects.
|
|
139
|
+
|
|
134
140
|
Applications requiring persistent cookies must supply a client through ``client``
|
|
135
141
|
or ``client_provider`` scoped to one authenticated principal and manage its lifetime.
|
|
136
142
|
Explicit outbound ``Cookie`` headers and response ``Set-Cookie`` headers are preserved;
|
|
@@ -162,6 +168,22 @@ class DefaultHttpRequestHandler:
|
|
|
162
168
|
if not info.method:
|
|
163
169
|
raise ValueError("HttpRequestInfo.method must be a non-empty string.")
|
|
164
170
|
|
|
171
|
+
url = httpx.URL(info.url)
|
|
172
|
+
if not url.is_absolute_url or url.scheme not in {"http", "https"}:
|
|
173
|
+
raise ValueError("HttpRequestInfo.url must be an absolute HTTP or HTTPS URL.")
|
|
174
|
+
|
|
175
|
+
# Preserve the authored query bytes, appending rather than replacing duplicate
|
|
176
|
+
# keys. QueryParams would rewrite escapes, bare flags and duplicate ordering.
|
|
177
|
+
query = urlsplit(info.url).query
|
|
178
|
+
explicit_pairs = [(key, value) for key, value in info.query_parameters.items() if key]
|
|
179
|
+
if explicit_pairs:
|
|
180
|
+
query += ("&" if query else "") + urlencode(explicit_pairs, quote_via=quote)
|
|
181
|
+
raw_path = url.raw_path.split(b"?", 1)[0]
|
|
182
|
+
if query:
|
|
183
|
+
raw_path += b"?" + _encode_query(query)
|
|
184
|
+
url = url.copy_with(raw_path=raw_path)
|
|
185
|
+
|
|
186
|
+
info = replace(info, url=str(url), headers=dict(info.headers), query_parameters={})
|
|
165
187
|
client = await self._resolve_client(info)
|
|
166
188
|
|
|
167
189
|
timeout: httpx.Timeout | object
|
|
@@ -181,54 +203,26 @@ class DefaultHttpRequestHandler:
|
|
|
181
203
|
# callers (not just the YAML executor) get sensible defaults.
|
|
182
204
|
headers["Content-Type"] = info.body_content_type or "text/plain"
|
|
183
205
|
|
|
184
|
-
#
|
|
185
|
-
#
|
|
186
|
-
|
|
187
|
-
# httpx 0.28.1, that rewrites ``%20`` in a value as ``+``, expands a bare
|
|
188
|
-
# ``download`` into ``download=``, and reorders interleaved duplicates
|
|
189
|
-
# (``x=1&y=2&x=3`` becomes ``x=1&x=3&y=2``), all of it even when nothing needed
|
|
190
|
-
# merging. Base64 and ``%3A`` round-trip unchanged, so a signature is not
|
|
191
|
-
# rewritten by itself, but any scheme that signs a value holding an encoded
|
|
192
|
-
# space is, and a server that distinguishes ``download`` from ``download=`` or
|
|
193
|
-
# reads repeated keys positionally sees a different request either way.
|
|
194
|
-
# Building the query here and writing it back over the request's ``raw_path``
|
|
195
|
-
# keeps the caller's bytes exactly as given.
|
|
196
|
-
raw = urlsplit(info.url)
|
|
197
|
-
|
|
198
|
-
# ``query_parameters`` append rather than replace, so a URL carrying
|
|
199
|
-
# ``filter=region&filter=status`` plus ``{"filter": "tenant"}`` sends all three,
|
|
200
|
-
# matching the .NET handler. Client-level params are defaults only: they apply
|
|
201
|
-
# for a key absent from both request-level sources.
|
|
202
|
-
explicit_pairs = [(key, value) for key, value in info.query_parameters.items() if key]
|
|
203
|
-
request_keys = {key for key, _ in parse_qsl(raw.query, keep_blank_values=True)}
|
|
204
|
-
request_keys.update(key for key, _ in explicit_pairs)
|
|
206
|
+
# Selected-client params remain defaults for keys absent from the workflow's
|
|
207
|
+
# composed query. They are host configuration, unavailable before selection.
|
|
208
|
+
request_keys = {key for key, _ in parse_qsl(url.query.decode("ascii"), keep_blank_values=True)}
|
|
205
209
|
client_defaults = [(key, value) for key, value in client.params.multi_items() if key not in request_keys]
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
# ``quote_via=quote`` keeps spaces as ``%20`` instead of ``+``, so appended
|
|
211
|
-
# parameters are encoded the same way the preserved URL query is.
|
|
212
|
-
for pairs in (explicit_pairs, client_defaults):
|
|
213
|
-
if pairs:
|
|
214
|
-
query_segments.append(urlencode(pairs, quote_via=quote))
|
|
215
|
-
query = "&".join(segment for segment in query_segments if segment)
|
|
210
|
+
if client_defaults:
|
|
211
|
+
encoded_query = url.query + (b"&" if url.query else b"")
|
|
212
|
+
encoded_query += urlencode(client_defaults, quote_via=quote).encode("ascii")
|
|
213
|
+
url = url.copy_with(query=encoded_query)
|
|
216
214
|
|
|
217
215
|
# Build without the query so the client's params merge has nothing to clobber --
|
|
218
216
|
# it drops the URL's query outright -- then overwrite the query httpx composed
|
|
219
217
|
# with ours. The fragment stays on the built URL; httpx does not send it.
|
|
220
218
|
request = client.build_request(
|
|
221
219
|
method=info.method,
|
|
222
|
-
url=
|
|
220
|
+
url=url.copy_with(query=None),
|
|
223
221
|
headers=headers or None,
|
|
224
222
|
content=content,
|
|
225
223
|
timeout=timeout,
|
|
226
224
|
)
|
|
227
|
-
|
|
228
|
-
raw_path = request.url.raw_path.split(b"?", 1)[0]
|
|
229
|
-
if query:
|
|
230
|
-
raw_path += b"?" + _encode_query(query)
|
|
231
|
-
request.url = request.url.copy_with(raw_path=raw_path)
|
|
225
|
+
request.url = url
|
|
232
226
|
|
|
233
227
|
response = await client.send(request)
|
|
234
228
|
|
|
@@ -455,7 +455,9 @@ def search_table(table: Any, value: Any, column: str) -> list[Any]:
|
|
|
455
455
|
return []
|
|
456
456
|
|
|
457
457
|
results: list[Any] = []
|
|
458
|
-
|
|
458
|
+
# Only Blank (None) and "" mean "no search value, return every row"; a
|
|
459
|
+
# falsy 0 or False is still the value the caller asked to match.
|
|
460
|
+
search_value = str(value).lower() if value is not None else ""
|
|
459
461
|
|
|
460
462
|
for item in cast(list[Any], table):
|
|
461
463
|
item_value: Any = None
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""Structural budgets for state copying and PowerFx symbol conversion.
|
|
4
|
+
|
|
5
|
+
Each traversal has its own budget and rejects cycles or excess with ValueError,
|
|
6
|
+
rather than truncating values. These limits do not bound expression execution
|
|
7
|
+
or application-defined Python copy/conversion hooks.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any, cast
|
|
14
|
+
|
|
15
|
+
_MAX_POWERFX_STATE_DEPTH = 64
|
|
16
|
+
_MAX_POWERFX_STATE_NODES = 10_000
|
|
17
|
+
_MAX_POWERFX_STATE_TEXT_SIZE = 1_048_576
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _PowerFxStateLimitError(ValueError):
|
|
21
|
+
"""State cannot be copied or marshalled within the PowerFx budget."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class _PowerFxStateBudget:
|
|
26
|
+
"""Count values, containers, and mapping keys, including repeated aliases.
|
|
27
|
+
|
|
28
|
+
Depth starts at zero. Text size counts string characters and binary bytes,
|
|
29
|
+
not encoded size or total memory.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
nodes: int = 0
|
|
33
|
+
text_size: int = 0
|
|
34
|
+
|
|
35
|
+
def consume(self, item: Any, depth: int) -> None:
|
|
36
|
+
self.nodes += 1
|
|
37
|
+
if self.nodes > _MAX_POWERFX_STATE_NODES:
|
|
38
|
+
raise _PowerFxStateLimitError("PowerFx state exceeds the node budget")
|
|
39
|
+
if depth > _MAX_POWERFX_STATE_DEPTH:
|
|
40
|
+
raise _PowerFxStateLimitError("PowerFx state exceeds the depth budget")
|
|
41
|
+
if isinstance(item, (str, bytes, bytearray)):
|
|
42
|
+
self.text_size += len(item)
|
|
43
|
+
if self.text_size > _MAX_POWERFX_STATE_TEXT_SIZE:
|
|
44
|
+
raise _PowerFxStateLimitError("PowerFx state exceeds the text size budget")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _validate_powerfx_state(value: Any) -> None: # pyright: ignore[reportUnusedFunction]
|
|
48
|
+
"""Reject cyclic or over-budget data before copying or conversion."""
|
|
49
|
+
budget = _PowerFxStateBudget()
|
|
50
|
+
active: set[int] = set()
|
|
51
|
+
|
|
52
|
+
def visit(item: Any, depth: int) -> None:
|
|
53
|
+
budget.consume(item, depth)
|
|
54
|
+
if item is None or isinstance(item, (str, bytes, bytearray, bool, int, float)):
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
identity = id(item)
|
|
58
|
+
if identity in active:
|
|
59
|
+
raise _PowerFxStateLimitError("PowerFx state contains a cycle")
|
|
60
|
+
active.add(identity)
|
|
61
|
+
try:
|
|
62
|
+
if isinstance(item, Enum):
|
|
63
|
+
visit(item.value, depth + 1)
|
|
64
|
+
elif isinstance(item, Mapping):
|
|
65
|
+
for key, member in cast(Mapping[Any, Any], item).items():
|
|
66
|
+
visit(key, depth + 1)
|
|
67
|
+
visit(member, depth + 1)
|
|
68
|
+
elif isinstance(item, (list, tuple, set, frozenset)):
|
|
69
|
+
for member in cast(list[Any] | tuple[Any, ...] | set[Any] | frozenset[Any], item):
|
|
70
|
+
visit(member, depth + 1)
|
|
71
|
+
elif hasattr(item, "__dict__"):
|
|
72
|
+
visit(vars(item), depth + 1)
|
|
73
|
+
finally:
|
|
74
|
+
active.remove(identity)
|
|
75
|
+
|
|
76
|
+
visit(value, 0)
|
|
@@ -16,6 +16,9 @@ import uuid
|
|
|
16
16
|
from collections.abc import Mapping
|
|
17
17
|
from typing import Any, cast
|
|
18
18
|
|
|
19
|
+
from ._powerfx_limits import _PowerFxStateLimitError, _validate_powerfx_state # pyright: ignore[reportPrivateUsage]
|
|
20
|
+
from ._state_path import _is_safe_path_segment # pyright: ignore[reportPrivateUsage]
|
|
21
|
+
|
|
19
22
|
try:
|
|
20
23
|
from powerfx import Engine
|
|
21
24
|
|
|
@@ -29,7 +32,10 @@ logger = logging.getLogger("agent_framework.declarative")
|
|
|
29
32
|
|
|
30
33
|
|
|
31
34
|
class WorkflowState:
|
|
32
|
-
"""Manages
|
|
35
|
+
"""Manages standalone workflow state and expression evaluation.
|
|
36
|
+
|
|
37
|
+
Workflows created by ``WorkflowFactory`` use a separate, checkpoint-backed
|
|
38
|
+
``DeclarativeWorkflowState`` implementation.
|
|
33
39
|
|
|
34
40
|
WorkflowState provides a unified interface for:
|
|
35
41
|
|
|
@@ -105,6 +111,7 @@ class WorkflowState:
|
|
|
105
111
|
inputs: Initial inputs to the workflow. These become available
|
|
106
112
|
as Workflow.Inputs.* and are immutable after initialization.
|
|
107
113
|
"""
|
|
114
|
+
_validate_powerfx_state(inputs)
|
|
108
115
|
self._inputs: dict[str, Any] = dict(inputs) if inputs else {}
|
|
109
116
|
self._local: dict[str, Any] = {}
|
|
110
117
|
self._outputs: dict[str, Any] = {}
|
|
@@ -158,12 +165,16 @@ class WorkflowState:
|
|
|
158
165
|
def get(self, path: str, default: Any = None) -> Any:
|
|
159
166
|
"""Get a value from the state using a dot-notated path.
|
|
160
167
|
|
|
168
|
+
Dict-keyed segments may use arbitrary string keys. Segments resolved
|
|
169
|
+
through object-attribute access must match ``[A-Za-z][A-Za-z0-9_]*``;
|
|
170
|
+
other shapes return ``default`` without accessing the attribute.
|
|
171
|
+
|
|
161
172
|
Args:
|
|
162
173
|
path: Dot-notated path like 'Local.results' or 'Workflow.Inputs.query'
|
|
163
174
|
default: Default value if path doesn't exist
|
|
164
175
|
|
|
165
176
|
Returns:
|
|
166
|
-
The value at the path, or default if not found
|
|
177
|
+
The value at the path, or default if not found or unreachable
|
|
167
178
|
"""
|
|
168
179
|
parts = path.split(".")
|
|
169
180
|
if not parts:
|
|
@@ -203,10 +214,18 @@ class WorkflowState:
|
|
|
203
214
|
obj = obj_dict.get(part, default)
|
|
204
215
|
if obj is default:
|
|
205
216
|
return default
|
|
206
|
-
elif hasattr(obj, part):
|
|
207
|
-
obj = getattr(obj, part)
|
|
208
217
|
else:
|
|
209
|
-
|
|
218
|
+
if not _is_safe_path_segment(part):
|
|
219
|
+
logger.warning(
|
|
220
|
+
"WorkflowState.get: rejecting attribute segment %r in path %r",
|
|
221
|
+
part,
|
|
222
|
+
path,
|
|
223
|
+
)
|
|
224
|
+
return default
|
|
225
|
+
if hasattr(obj, part):
|
|
226
|
+
obj = getattr(obj, part)
|
|
227
|
+
else:
|
|
228
|
+
return default
|
|
210
229
|
|
|
211
230
|
return obj
|
|
212
231
|
|
|
@@ -329,7 +348,19 @@ class WorkflowState:
|
|
|
329
348
|
|
|
330
349
|
Returns:
|
|
331
350
|
A dictionary suitable for passing to PowerFx Engine.eval()
|
|
351
|
+
|
|
352
|
+
Raises:
|
|
353
|
+
ValueError: If the state or projected symbols exceed the PowerFx state budget.
|
|
332
354
|
"""
|
|
355
|
+
_validate_powerfx_state({
|
|
356
|
+
"Inputs": self._inputs,
|
|
357
|
+
"Outputs": self._outputs,
|
|
358
|
+
"Local": self._local,
|
|
359
|
+
"System": self._system,
|
|
360
|
+
"Agent": self._agent,
|
|
361
|
+
"Conversation": self._conversation,
|
|
362
|
+
"Custom": self._custom,
|
|
363
|
+
})
|
|
333
364
|
symbols = {
|
|
334
365
|
"Workflow": {
|
|
335
366
|
"Inputs": dict(self._inputs),
|
|
@@ -345,24 +376,31 @@ class WorkflowState:
|
|
|
345
376
|
}
|
|
346
377
|
# Debug log the Local symbols to help diagnose type issues
|
|
347
378
|
if self._local:
|
|
348
|
-
for
|
|
379
|
+
for value in self._local.values():
|
|
349
380
|
logger.debug(
|
|
350
|
-
|
|
351
|
-
|
|
381
|
+
"PowerFx Local symbol type=%s",
|
|
382
|
+
type(value).__name__,
|
|
352
383
|
)
|
|
384
|
+
_validate_powerfx_state(symbols)
|
|
353
385
|
return symbols
|
|
354
386
|
|
|
355
387
|
def eval(self, expression: str) -> Any:
|
|
356
388
|
"""Evaluate a PowerFx expression with the current state.
|
|
357
389
|
|
|
358
390
|
Expressions starting with '=' are evaluated as PowerFx.
|
|
359
|
-
Other strings are returned as-is
|
|
391
|
+
Other strings are returned as-is. If PowerFx is unavailable or raises
|
|
392
|
+
an ordinary evaluation error, the simple evaluator is used instead.
|
|
393
|
+
Its state references follow :meth:`get`'s object-member rules.
|
|
394
|
+
State-budget errors propagate without falling back.
|
|
360
395
|
|
|
361
396
|
Args:
|
|
362
397
|
expression: The expression to evaluate
|
|
363
398
|
|
|
364
399
|
Returns:
|
|
365
400
|
The evaluated result, or the original expression if not a PowerFx expression
|
|
401
|
+
|
|
402
|
+
Raises:
|
|
403
|
+
ValueError: If symbol construction exceeds the PowerFx state budget.
|
|
366
404
|
"""
|
|
367
405
|
if not expression:
|
|
368
406
|
return expression
|
|
@@ -378,6 +416,8 @@ class WorkflowState:
|
|
|
378
416
|
try:
|
|
379
417
|
symbols = self.to_powerfx_symbols()
|
|
380
418
|
return _powerfx_engine.eval(formula, symbols=symbols)
|
|
419
|
+
except _PowerFxStateLimitError:
|
|
420
|
+
raise
|
|
381
421
|
except Exception as exc:
|
|
382
422
|
logger.warning(f"PowerFx evaluation failed for '{expression[:50]}': {exc}")
|
|
383
423
|
# Fall through to simple evaluation
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""Shared identifier policy for object-attribute steps in workflow state paths."""
|
|
4
|
+
|
|
5
|
+
import regex
|
|
6
|
+
|
|
7
|
+
_SAFE_PATH_SEGMENT_RE = regex.compile(r"[A-Za-z][A-Za-z0-9_]*")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _is_safe_path_segment(segment: str) -> bool: # pyright: ignore[reportUnusedFunction]
|
|
11
|
+
"""Return whether an object-attribute segment is a declarative identifier."""
|
|
12
|
+
return _SAFE_PATH_SEGMENT_RE.fullmatch(segment) is not None
|
|
File without changes
|
|
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
|
|
|
4
4
|
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
7
|
-
version = "1.1
|
|
7
|
+
version = "1.2.1"
|
|
8
8
|
license-files = ["LICENSE"]
|
|
9
9
|
urls.homepage = "https://aka.ms/agent-framework"
|
|
10
10
|
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
|
@@ -22,7 +22,7 @@ classifiers = [
|
|
|
22
22
|
"Typing :: Typed",
|
|
23
23
|
]
|
|
24
24
|
dependencies = [
|
|
25
|
-
"agent-framework-core>=1.
|
|
25
|
+
"agent-framework-core>=1.20.0,<2",
|
|
26
26
|
"httpx>=0.27,<1",
|
|
27
27
|
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
|
|
28
28
|
"pyyaml>=6.0,<7.0",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|