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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/PKG-INFO +2 -2
  2. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_declarative_base.py +75 -32
  3. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_executors_agents.py +74 -64
  4. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_executors_basic.py +8 -5
  5. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_executors_mcp.py +81 -27
  6. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_executors_tools.py +17 -8
  7. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_factory.py +6 -3
  8. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_http_handler.py +33 -39
  9. agent_framework_declarative-1.2.0/agent_framework_declarative/_workflows/_powerfx_limits.py +76 -0
  10. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_state.py +49 -9
  11. agent_framework_declarative-1.2.0/agent_framework_declarative/_workflows/_state_path.py +12 -0
  12. agent_framework_declarative-1.2.0/agent_framework_declarative/py.typed +0 -0
  13. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/pyproject.toml +2 -2
  14. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/LICENSE +0 -0
  15. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/README.md +0 -0
  16. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/agent_framework_declarative/__init__.py +0 -0
  17. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_feature_usage.py +0 -0
  18. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_loader.py +0 -0
  19. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_models.py +0 -0
  20. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/__init__.py +0 -0
  21. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_declarative_builder.py +0 -0
  22. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_errors.py +0 -0
  23. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_executors_control_flow.py +0 -0
  24. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_executors_external_input.py +0 -0
  25. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_executors_http.py +0 -0
  26. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_mcp_handler.py +0 -0
  27. {agent_framework_declarative-1.1.0 → agent_framework_declarative-1.2.0}/agent_framework_declarative/_workflows/_powerfx_functions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-framework-declarative
3
- Version: 1.1.0
3
+ Version: 1.2.0
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.19.0,<2
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
- def visit(value: Any) -> None:
227
+ while stack:
228
+ parent_id, children = stack[-1]
229
+ try:
230
+ value = next(children)
231
+ except StopIteration:
232
+ stack.pop()
233
+ if parent_id is not None:
234
+ active.remove(parent_id)
235
+ continue
212
236
  if isinstance(value, str):
213
237
  if value.startswith("="):
214
238
  names.update(_ENV_REFERENCE_RE.findall(value))
215
- return
216
- if isinstance(value, Mapping):
217
- for inner in cast(Mapping[Any, Any], value).values():
218
- visit(inner)
219
- return
220
- if isinstance(value, list):
221
- for item in cast(list[Any], value):
222
- visit(item)
239
+ continue
240
+ if not isinstance(value, (Mapping, list)):
241
+ continue
242
+
243
+ container = cast(Mapping[Any, Any] | list[Any], value)
244
+ container_id = id(container)
245
+ if container_id in active:
246
+ raise DeclarativeWorkflowError("Cyclic mappings or lists are not supported in workflow definitions.")
247
+ if container_id in visited:
248
+ continue
249
+ visited[container_id] = container
250
+ active.add(container_id)
251
+ children = iter(container.values()) if isinstance(container, Mapping) else iter(container)
252
+ stack.append((container_id, children))
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
- if value is None:
293
- return value
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 _make_powerfx_safe(value.value)
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
- return {str(k): _make_powerfx_safe(v) for k, v in value_dict.items()}
311
-
312
- if isinstance(value, list):
313
- value_list = cast(list[Any], value)
314
- return [_make_powerfx_safe(item) for item in value_list]
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
- # For other objects, try to convert to string representation
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._state.set(DECLARATIVE_STATE_KEY, state_data)
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 _SAFE_PATH_SEGMENT_RE.match(part):
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 key, value in local_data.items():
960
+ for value in local_data.values():
918
961
  logger.debug(
919
- f"PowerFx symbol Local.{key}: type={type(value).__name__}, "
920
- f"value_preview={str(value)[:100] if value else None}"
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 configuration.
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
- # Evaluate messages/input
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
- return evaluated_input
696
- if isinstance(evaluated_input, list) and evaluated_input:
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
- return last
701
- if isinstance(last, dict):
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
- return str(content_val) if content_val else ""
705
- if last is not None and hasattr(last, "text"): # type: ignore
706
- return str(getattr(last, "text", "")) # type: ignore
707
- if evaluated_input:
708
- return str(cast(Any, evaluated_input))
709
- return ""
710
-
711
- # Fallback chain for implicit input (like .NET conversationId pattern):
712
- # 1. Local.input / Local.userInput (explicit turn state)
713
- # 2. System.LastMessage.Text (previous agent's response)
714
- # 3. Workflow.Inputs (first agent gets workflow inputs)
715
- input_text: str = str(state.get("Local.input") or state.get("Local.userInput") or "")
716
- if not input_text:
717
- # Try System.LastMessage.Text (used by external loop and agent chaining)
718
- last_message: Any = state.get("System.LastMessage")
719
- if isinstance(last_message, dict):
720
- last_msg_dict = cast(dict[str, Any], last_message)
721
- text_val: Any = last_msg_dict.get("Text", "")
722
- input_text = str(text_val) if text_val else ""
723
- if not input_text:
724
- # Fall back to workflow inputs (for first agent in chain)
725
- inputs: Any = state.get("Workflow.Inputs")
726
- if isinstance(inputs, dict):
727
- inputs_dict = cast(dict[str, Any], inputs)
728
- # If single input, use its value directly
729
- if len(inputs_dict) == 1:
730
- input_text = str(next(iter(inputs_dict.values())))
731
- else:
732
- # Multiple inputs - format as key: value pairs
733
- input_text = "\n".join(f"{k}: {v}" for k, v in inputs_dict.items())
734
- return input_text if input_text else ""
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 _agent_utils as workflow_agent_utils
792
- from agent_framework._workflows import _const as workflow_const
793
-
794
- run_kwargs: dict[str, Any] = ctx.get_state(workflow_const.WORKFLOW_RUN_KWARGS_KEY, {})
795
- prepare_run_kwargs = getattr(workflow_agent_utils, "prepare_executor_run_kwargs", None)
796
- resolved_state_key = getattr(workflow_const, "RESOLVED_WORKFLOW_RUN_KWARGS_KEY", None)
797
- if callable(prepare_run_kwargs) and isinstance(resolved_state_key, str):
798
- missing_resolved_state = object()
799
- resolved_run_kwargs: Any = ctx.get_state(resolved_state_key, missing_resolved_state)
800
- if resolved_run_kwargs is not missing_resolved_state:
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=loop_state.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
- # First evaluate any =expression syntax
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 on resume to keep secrets out of
17
- checkpoint state.
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 invocation using the values pinned on ``original_request``."""
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, bool]:
247
- """Parse output configuration from action definition.
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, auto_send)
250
+ Tuple of (messages_var, result_var, auto_send_expr)
251
251
  """
252
- output_config: dict[str, str | bool] = self._action_def.get("output", {})
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
- auto_send,
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, auto_send = self._get_output_config()
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, auto_send = self._get_output_config()
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 or cannot be parsed.
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 or cannot be parsed.
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 or missing required fields.
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, urlunsplit
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
- # Compose the query as raw bytes rather than routing it through ``params=``.
185
- # Anything that reaches ``httpx.QueryParams`` -- ``params=`` or the client's own
186
- # ``AsyncClient.params`` merge -- is decoded and re-encoded. Measured against
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
- query_segments: list[str] = []
208
- if raw.query:
209
- query_segments.append(raw.query)
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=urlunsplit((raw.scheme, raw.netloc, raw.path, "", raw.fragment)),
220
+ url=url.copy_with(query=None),
223
221
  headers=headers or None,
224
222
  content=content,
225
223
  timeout=timeout,
226
224
  )
227
- # Reuse httpx's own path encoding and replace only the query part of ``raw_path``.
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
 
@@ -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 variables and state during declarative workflow execution.
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
- return default
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 key, value in self._local.items():
379
+ for value in self._local.values():
349
380
  logger.debug(
350
- f"PowerFx symbol Local.{key}: type={type(value).__name__}, "
351
- f"value_preview={str(value)[:100] if value else None}"
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 (after variable interpolation if applicable).
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
@@ -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.0"
7
+ version = "1.2.0"
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.19.0,<2",
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",