agent-framework-declarative 1.2.1__tar.gz → 1.4.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.2.1 → agent_framework_declarative-1.4.0}/PKG-INFO +36 -4
  2. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/README.md +30 -0
  3. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_loader.py +13 -12
  4. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_declarative_base.py +24 -91
  5. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_declarative_builder.py +56 -12
  6. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_executors_basic.py +6 -1
  7. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_executors_control_flow.py +5 -1
  8. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_executors_mcp.py +3 -3
  9. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_factory.py +1 -1
  10. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_http_handler.py +162 -2
  11. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_mcp_handler.py +24 -10
  12. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_powerfx_functions.py +147 -0
  13. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_state.py +6 -6
  14. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/pyproject.toml +5 -4
  15. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/LICENSE +0 -0
  16. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/__init__.py +0 -0
  17. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_feature_usage.py +0 -0
  18. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_models.py +0 -0
  19. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/__init__.py +0 -0
  20. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_errors.py +0 -0
  21. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_executors_agents.py +0 -0
  22. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_executors_external_input.py +0 -0
  23. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_executors_http.py +0 -0
  24. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_executors_tools.py +0 -0
  25. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_powerfx_limits.py +0 -0
  26. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_state_path.py +0 -0
  27. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/py.typed +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: agent-framework-declarative
3
- Version: 1.2.1
3
+ Version: 1.4.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,14 +15,16 @@ 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.20.0,<2
19
- Requires-Dist: httpx>=0.27,<1
18
+ Requires-Dist: agent-framework-core>=1.23.0,<2
19
+ Requires-Dist: httpx>=0.27,<0.29
20
+ Requires-Dist: httpx2>=2.0.0,<3
20
21
  Requires-Dist: powerfx>=0.0.32,<0.0.35; python_version < '3.14'
21
22
  Requires-Dist: pyyaml>=6.0,<7.0
22
23
  Project-URL: homepage, https://aka.ms/agent-framework
23
24
  Project-URL: issues, https://github.com/microsoft/agent-framework/issues
24
25
  Project-URL: release_notes, https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true
25
26
  Project-URL: source, https://github.com/microsoft/agent-framework/tree/main/python
27
+ Import-Name: agent_framework_declarative
26
28
 
27
29
  # Get Started with Microsoft Agent Framework Declarative
28
30
 
@@ -32,6 +34,11 @@ Please install this package via pip:
32
34
  pip install agent-framework-declarative
33
35
  ```
34
36
 
37
+ The package currently advertises Python 3.10–3.13 support. Python 3.14 and
38
+ 3.15 can install the package, but Power Fx expressions are unavailable because
39
+ the `powerfx` dependency requires a `pythonnet` build that does not support
40
+ those Python versions.
41
+
35
42
  ## Release stage
36
43
 
37
44
  This package ships at two different stability levels:
@@ -66,6 +73,31 @@ Explicit `Cookie` request headers remain supported. Response `Set-Cookie` header
66
73
  remain available in `HttpRequestResult.headers`; disabling persistence does not redact
67
74
  the response.
68
75
 
76
+ ## HTTP response body limits
77
+
78
+ `DefaultHttpRequestHandler` limits response bodies to **10 MiB** by default.
79
+ Configure a positive integer `max_response_body_bytes` on the handler when a host
80
+ requires a different bound. The bound applies to received bytes and each decoded
81
+ stage, including intermediate redirect and authentication responses. Responses
82
+ above the bound raise `httpx.StreamError` before text conversion or workflow
83
+ assignment, and their streams are closed without closing caller-owned clients.
84
+
85
+ The handler restricts HTTPX's generated `Accept-Encoding` default to `gzip, deflate`
86
+ and supports identity, gzip, and deflate
87
+ responses, including up to four encoding stages. Unsupported encodings,
88
+ incomplete encoded bodies, and trailing encoded data raise `httpx.DecodingError`.
89
+ Empty encoded responses are validated unless the response is bodyless (HEAD,
90
+ 1xx, 204, 205, 304, or a successful CONNECT response). Deflate input is retained
91
+ within the same bound until its wrapped or raw format can be validated.
92
+ Explicit request `Accept-Encoding` headers and client values that differ from
93
+ HTTPX's generated default are preserved but do not enable additional decoders.
94
+ Caller-supplied response hooks and transports must not
95
+ buffer response bodies before the handler applies its limit.
96
+
97
+ HTTPX support is limited to the 0.27 and 0.28 series because the bounded
98
+ intermediate-response cache and generated default-header integration depend
99
+ on HTTPX internals. Compatibility tests cover HTTPX 0.27.0 and 0.28.1.
100
+
69
101
  ## MCP handler session lifetime
70
102
 
71
103
  `DefaultMCPToolHandler()` reuses MCP sessions through a bounded LRU cache.
@@ -6,6 +6,11 @@ Please install this package via pip:
6
6
  pip install agent-framework-declarative
7
7
  ```
8
8
 
9
+ The package currently advertises Python 3.10–3.13 support. Python 3.14 and
10
+ 3.15 can install the package, but Power Fx expressions are unavailable because
11
+ the `powerfx` dependency requires a `pythonnet` build that does not support
12
+ those Python versions.
13
+
9
14
  ## Release stage
10
15
 
11
16
  This package ships at two different stability levels:
@@ -40,6 +45,31 @@ Explicit `Cookie` request headers remain supported. Response `Set-Cookie` header
40
45
  remain available in `HttpRequestResult.headers`; disabling persistence does not redact
41
46
  the response.
42
47
 
48
+ ## HTTP response body limits
49
+
50
+ `DefaultHttpRequestHandler` limits response bodies to **10 MiB** by default.
51
+ Configure a positive integer `max_response_body_bytes` on the handler when a host
52
+ requires a different bound. The bound applies to received bytes and each decoded
53
+ stage, including intermediate redirect and authentication responses. Responses
54
+ above the bound raise `httpx.StreamError` before text conversion or workflow
55
+ assignment, and their streams are closed without closing caller-owned clients.
56
+
57
+ The handler restricts HTTPX's generated `Accept-Encoding` default to `gzip, deflate`
58
+ and supports identity, gzip, and deflate
59
+ responses, including up to four encoding stages. Unsupported encodings,
60
+ incomplete encoded bodies, and trailing encoded data raise `httpx.DecodingError`.
61
+ Empty encoded responses are validated unless the response is bodyless (HEAD,
62
+ 1xx, 204, 205, 304, or a successful CONNECT response). Deflate input is retained
63
+ within the same bound until its wrapped or raw format can be validated.
64
+ Explicit request `Accept-Encoding` headers and client values that differ from
65
+ HTTPX's generated default are preserved but do not enable additional decoders.
66
+ Caller-supplied response hooks and transports must not
67
+ buffer response bodies before the handler applies its limit.
68
+
69
+ HTTPX support is limited to the 0.27 and 0.28 series because the bounded
70
+ intermediate-response cache and generated default-header integration depend
71
+ on HTTPX internals. Compatibility tests cover HTTPX 0.27.0 and 0.28.1.
72
+
43
73
  ## MCP handler session lifetime
44
74
 
45
75
  `DefaultMCPToolHandler()` reuses MCP sessions through a bounded LRU cache.
@@ -290,7 +290,7 @@ class AgentFactory:
290
290
  load_dotenv(dotenv_path=env_file_path, encoding=env_file_encoding)
291
291
 
292
292
  def create_agent_from_yaml_path(self, yaml_path: str | Path) -> Agent:
293
- """Create a Agent from a YAML file path.
293
+ """Create an Agent from a YAML file path.
294
294
 
295
295
  This method does the following things:
296
296
 
@@ -298,7 +298,7 @@ class AgentFactory:
298
298
  2. Validates that the loaded object is a PromptAgent.
299
299
  3. Creates the appropriate ChatClient based on the model provider and apiType.
300
300
  4. Parses the tools, options, and response format from the PromptAgent.
301
- 5. Creates and returns a Agent instance with the configured properties.
301
+ 5. Creates and returns an Agent instance with the configured properties.
302
302
 
303
303
  Args:
304
304
  yaml_path: Path to the YAML file representation of a PromptAgent.
@@ -344,7 +344,7 @@ class AgentFactory:
344
344
  return self.create_agent_from_yaml(yaml_str)
345
345
 
346
346
  def create_agent_from_yaml(self, yaml_str: str) -> Agent:
347
- """Create a Agent from a YAML string.
347
+ """Create an Agent from a YAML string.
348
348
 
349
349
  This method does the following things:
350
350
 
@@ -352,7 +352,7 @@ class AgentFactory:
352
352
  2. Validates that the loaded object is a PromptAgent.
353
353
  3. Creates the appropriate ChatClient based on the model provider and apiType.
354
354
  4. Parses the tools, options, and response format from the PromptAgent.
355
- 5. Creates and returns a Agent instance with the configured properties.
355
+ 5. Creates and returns an Agent instance with the configured properties.
356
356
 
357
357
  Args:
358
358
  yaml_str: YAML string representation of a PromptAgent.
@@ -417,7 +417,7 @@ class AgentFactory:
417
417
  return self.create_agent_from_dict(yaml.safe_load(yaml_str))
418
418
 
419
419
  def create_agent_from_dict(self, agent_def: dict[str, Any]) -> Agent:
420
- """Create a Agent from a dictionary definition.
420
+ """Create an Agent from a dictionary definition.
421
421
 
422
422
  This method does the following things:
423
423
 
@@ -425,7 +425,7 @@ class AgentFactory:
425
425
  2. Validates that the loaded object is a PromptAgent.
426
426
  3. Creates the appropriate ChatClient based on the model provider and apiType.
427
427
  4. Parses the tools, options, and response format from the PromptAgent.
428
- 5. Creates and returns a Agent instance with the configured properties.
428
+ 5. Creates and returns an Agent instance with the configured properties.
429
429
 
430
430
  Args:
431
431
  agent_def: Dictionary representation of a PromptAgent.
@@ -485,7 +485,7 @@ class AgentFactory:
485
485
  return agent
486
486
 
487
487
  async def create_agent_from_yaml_path_async(self, yaml_path: str | Path) -> Agent:
488
- """Async version: Create a Agent from a YAML file path.
488
+ """Async version: Create an Agent from a YAML file path.
489
489
 
490
490
  This is the async counterpart to ``create_agent_from_dict`` and is useful when
491
491
  the rest of your setup is already async.
@@ -516,7 +516,7 @@ class AgentFactory:
516
516
  return await self.create_agent_from_yaml_async(yaml_str)
517
517
 
518
518
  async def create_agent_from_yaml_async(self, yaml_str: str) -> Agent:
519
- """Async version: Create a Agent from a YAML string.
519
+ """Async version: Create an Agent from a YAML string.
520
520
 
521
521
  Use this method when the surrounding call site is already async and you
522
522
  want to build an agent directly from YAML text.
@@ -547,7 +547,7 @@ class AgentFactory:
547
547
  return await self.create_agent_from_dict_async(yaml.safe_load(yaml_str))
548
548
 
549
549
  async def create_agent_from_dict_async(self, agent_def: dict[str, Any]) -> Agent:
550
- """Async version: Create a Agent from a dictionary definition.
550
+ """Async version: Create an Agent from a dictionary definition.
551
551
 
552
552
  This is the async counterpart to ``create_agent_from_dict`` and is useful when
553
553
  the rest of your setup is already async.
@@ -740,10 +740,11 @@ class AgentFactory:
740
740
  chat_options["stop"] = options.stopSequences
741
741
  if options.allowMultipleToolCalls is not None:
742
742
  chat_options["allow_multiple_tool_calls"] = options.allowMultipleToolCalls
743
- if (chat_tool_mode := options.additionalProperties.pop("chatToolMode", None)) is not None:
743
+ additional_properties = options.additionalProperties.copy()
744
+ if (chat_tool_mode := additional_properties.pop("chatToolMode", None)) is not None:
744
745
  chat_options["tool_choice"] = chat_tool_mode
745
- if options.additionalProperties:
746
- chat_options["additional_chat_options"] = options.additionalProperties
746
+ if additional_properties:
747
+ chat_options["additional_chat_options"] = additional_properties
747
748
  return chat_options
748
749
 
749
750
  def _parse_tools(self, tools: list[Tool] | None) -> list[AFFunctionTool | dict[str, Any]] | None:
@@ -46,6 +46,10 @@ from agent_framework import (
46
46
  from agent_framework._workflows._state import State
47
47
 
48
48
  from ._errors import DeclarativeWorkflowError
49
+ from ._powerfx_functions import (
50
+ _iter_powerfx_expression_indices, # pyright: ignore[reportPrivateUsage]
51
+ whole_call_args,
52
+ )
49
53
  from ._powerfx_limits import _PowerFxStateBudget, _validate_powerfx_state # pyright: ignore[reportPrivateUsage]
50
54
  from ._state_path import _is_safe_path_segment # pyright: ignore[reportPrivateUsage]
51
55
 
@@ -68,73 +72,6 @@ logger = logging.getLogger(__name__)
68
72
  _ENV_REFERENCE_RE = re.compile(r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)")
69
73
 
70
74
 
71
- def _skip_powerfx_opaque_token(formula: str, start: int) -> int:
72
- """Return the end of an ordinary quoted token or comment, or start if neither."""
73
- quote = formula[start]
74
- if quote in ('"', "'"):
75
- pos = start + 1
76
- while pos < len(formula):
77
- if formula[pos] == quote:
78
- if pos + 1 < len(formula) and formula[pos + 1] == quote:
79
- pos += 2
80
- continue
81
- return pos + 1
82
- pos += 1
83
- return pos
84
- if formula.startswith("//", start):
85
- pos = start + 2
86
- while pos < len(formula) and formula[pos] not in "\r\n":
87
- pos += 1
88
- return pos
89
- if formula.startswith("/*", start):
90
- end = formula.find("*/", start + 2)
91
- return len(formula) if end == -1 else end + 2
92
- return start
93
-
94
-
95
- def _iter_powerfx_expression_indices(formula: str) -> Iterator[int]:
96
- """Yield code positions, excluding literals/comments but including interpolation expressions."""
97
- # None denotes interpolated text; integers track record braces in expression sections.
98
- scopes: list[int | None] = [0]
99
- cursor = 0
100
- while cursor < len(formula):
101
- depth = scopes[-1]
102
- char = formula[cursor]
103
- if depth is None:
104
- if formula.startswith(('""', "{{", "}}"), cursor):
105
- cursor += 2
106
- continue
107
- if char == "{":
108
- scopes.append(0)
109
- elif char == '"':
110
- scopes.pop()
111
- cursor += 1
112
- continue
113
-
114
- if formula.startswith('$"', cursor):
115
- scopes.append(None)
116
- cursor += 2
117
- continue
118
-
119
- token_end = _skip_powerfx_opaque_token(formula, cursor)
120
- if token_end != cursor:
121
- cursor = token_end
122
- continue
123
-
124
- if char == "{":
125
- scopes[-1] = depth + 1
126
- elif char == "}":
127
- if depth > 0:
128
- scopes[-1] = depth - 1
129
- elif len(scopes) > 1:
130
- scopes.pop()
131
- cursor += 1
132
- continue
133
-
134
- yield cursor
135
- cursor += 1
136
-
137
-
138
75
  @dataclass(frozen=True)
139
76
  class DeclarativeEnvConfig:
140
77
  """Configuration that populates the PowerFx ``Env`` symbol for a workflow.
@@ -721,50 +658,50 @@ class DeclarativeWorkflowState:
721
658
 
722
659
  Returns None if the formula is not a custom function call.
723
660
  """
724
- import re
661
+ formula = formula.strip()
725
662
 
726
663
  # Concat/Concatenate - string concatenation
727
664
  # In standard PowerFx, Concatenate is for strings, Concat is for tables.
728
665
  # Copilot Studio uses Concat for strings, so we support both.
729
- match = re.match(r"(?:Concat|Concatenate)\((.+)\)$", formula.strip())
730
- if match:
731
- args_str = match.group(1)
666
+ args_str = whole_call_args(formula, "Concat", "Concatenate")
667
+ # Empty arguments fall through so PowerFx reports the arity error.
668
+ if args_str:
732
669
  # Parse comma-separated arguments (handling nested parentheses)
733
670
  args = self._parse_function_args(args_str)
734
671
  evaluated_args: list[str] = []
735
672
  for arg in args:
736
673
  arg = arg.strip()
737
- if arg.startswith('"') and arg.endswith('"'):
674
+ if re.fullmatch(r'"(?:[^"]|"")*"', arg):
738
675
  # String literal
739
- evaluated_args.append(arg[1:-1])
676
+ evaluated_args.append(arg[1:-1].replace('""', '"'))
740
677
  elif arg.startswith("'") and arg.endswith("'"):
741
678
  # Single-quoted string literal
742
679
  evaluated_args.append(arg[1:-1])
743
680
  else:
744
- # Variable reference - evaluate it
681
+ # Variable reference or expression - evaluate it
745
682
  result = self.eval(f"={arg}")
746
683
  evaluated_args.append(str(result) if result is not None else "")
747
684
  return "".join(evaluated_args)
748
685
 
749
686
  # UserMessage(expr) - creates a user message dict
750
- match = re.match(r"UserMessage\((.+)\)$", formula.strip())
751
- if match:
752
- inner_expr = match.group(1).strip()
687
+ inner_args = whole_call_args(formula, "UserMessage")
688
+ if inner_args:
689
+ inner_expr = inner_args.strip()
753
690
  # Evaluate the inner expression
754
691
  text = self.eval(f"={inner_expr}")
755
692
  return {"role": "user", "text": str(text) if text else ""}
756
693
 
757
694
  # AgentMessage(expr) - creates an assistant message dict
758
- match = re.match(r"AgentMessage\((.+)\)$", formula.strip())
759
- if match:
760
- inner_expr = match.group(1).strip()
695
+ inner_args = whole_call_args(formula, "AgentMessage")
696
+ if inner_args:
697
+ inner_expr = inner_args.strip()
761
698
  text = self.eval(f"={inner_expr}")
762
699
  return {"role": "assistant", "text": str(text) if text else ""}
763
700
 
764
701
  # MessageText(expr) - extracts text from the last message
765
- match = re.match(r"MessageText\((.+)\)$", formula.strip())
766
- if match:
767
- inner_expr = match.group(1).strip()
702
+ inner_args = whole_call_args(formula, "MessageText")
703
+ if inner_args:
704
+ inner_expr = inner_args.strip()
768
705
  # Reuse the helper method for consistent text extraction
769
706
  return self._eval_and_replace_message_text(inner_expr)
770
707
 
@@ -983,8 +920,9 @@ class DeclarativeWorkflowState:
983
920
  Captures brace-delimited tokens whose root segment is an identifier
984
921
  (``[A-Za-z][A-Za-z0-9_]*``) followed by zero or more ``.`` separated
985
922
  dict-key segments. Resolution is delegated to :meth:`get`; unresolved
986
- tokens are replaced with the empty string. Tokens that do not look
987
- like state paths (e.g. ``{foo-bar}``, ``{Ctrl+C}``) are left literal.
923
+ tokens are replaced with the empty string. Substituted values remain
924
+ literal and are not interpolated again. Tokens that do not look like
925
+ state paths (e.g. ``{foo-bar}``, ``{Ctrl+C}``) are left literal.
988
926
 
989
927
  Args:
990
928
  text: Text that may contain {Variable.Path} references
@@ -1004,12 +942,7 @@ class DeclarativeWorkflowState:
1004
942
  # per-segment safety on attribute traversal.
1005
943
  pattern = r"\{([A-Za-z][A-Za-z0-9_]*(?:\.[^{}\s.]+)*)\}"
1006
944
 
1007
- result = text
1008
- for match in re.finditer(pattern, text):
1009
- replacement = replace_var(match)
1010
- result = result.replace(match.group(0), replacement, 1)
1011
-
1012
- return result
945
+ return re.sub(pattern, replace_var, text)
1013
946
 
1014
947
 
1015
948
  # Message types for inter-executor communication
@@ -173,7 +173,8 @@ class DeclarativeWorkflowBuilder:
173
173
  self._checkpoint_storage = checkpoint_storage
174
174
  self._pending_gotos: list[tuple[Any, str]] = [] # (goto_executor, target_id)
175
175
  self._validate = validate
176
- self._seen_explicit_ids: set[str] = set() # Track explicit IDs for duplicate detection
176
+ self._seen_explicit_ids: set[str] = set()
177
+ self._generated_ids: set[str] = set()
177
178
  self._http_request_handler = http_request_handler
178
179
  self._mcp_tool_handler = mcp_tool_handler
179
180
  self._env_config: DeclarativeEnvConfig = env_config if env_config is not None else DeclarativeEnvConfig()
@@ -201,6 +202,9 @@ class DeclarativeWorkflowBuilder:
201
202
  if self._validate:
202
203
  self._validate_workflow(actions)
203
204
 
205
+ # Reserve nested and forward-declared executor IDs before naming internal nodes.
206
+ self._collect_explicit_ids(actions)
207
+
204
208
  # Create a stable entry node as the start executor, then wire it to the first action.
205
209
  # This avoids needing a placeholder since the entry executor isn't known until after
206
210
  # _create_executors_for_actions runs (which itself needs the builder to add edges).
@@ -238,6 +242,39 @@ class DeclarativeWorkflowBuilder:
238
242
 
239
243
  return builder.build()
240
244
 
245
+ def _collect_explicit_ids(self, actions: list[dict[str, Any]] | None) -> None:
246
+ """Reserve authored executor IDs, excluding virtual structures and skipped actions."""
247
+ if not actions:
248
+ return
249
+ for action_def in actions:
250
+ kind = action_def.get("kind", "")
251
+ creates_executor = (
252
+ kind in ALL_ACTION_EXECUTORS
253
+ or kind in ("BreakLoop", "ContinueLoop")
254
+ or (kind == "GotoAction" and bool(action_def.get("target") or action_def.get("actionId")))
255
+ )
256
+ if creates_executor and (explicit_id := action_def.get("id")):
257
+ self._seen_explicit_ids.add(explicit_id)
258
+ if kind == "If":
259
+ self._collect_explicit_ids(action_def.get("then", action_def.get("actions", [])))
260
+ self._collect_explicit_ids(action_def.get("else", []))
261
+ elif kind == "ConditionGroup":
262
+ for branch in action_def.get("conditions", []):
263
+ self._collect_explicit_ids(branch.get("actions", []))
264
+ self._collect_explicit_ids(action_def.get("elseActions", []))
265
+ elif kind == "Foreach":
266
+ self._collect_explicit_ids(action_def.get("actions", []))
267
+
268
+ def _allocate_generated_id(self, preferred_id: str) -> str:
269
+ """Retain the existing ID unless an authored or generated ID already uses it."""
270
+ candidate = preferred_id
271
+ suffix = 2
272
+ while candidate in self._seen_explicit_ids or candidate in self._generated_ids or candidate in self._executors:
273
+ candidate = f"{preferred_id}_{suffix}"
274
+ suffix += 1
275
+ self._generated_ids.add(candidate)
276
+ return candidate
277
+
241
278
  def _validate_workflow(self, actions: list[dict[str, Any]]) -> None:
242
279
  """Validate the workflow definition before building.
243
280
 
@@ -489,7 +526,8 @@ class DeclarativeWorkflowBuilder:
489
526
  action_id = explicit_id
490
527
  else:
491
528
  parent_id = (parent_context or {}).get("parent_id")
492
- action_id = f"{parent_id}_{kind}_{self._action_index}" if parent_id else f"{kind}_{self._action_index}"
529
+ preferred_id = f"{parent_id}_{kind}_{self._action_index}" if parent_id else f"{kind}_{self._action_index}"
530
+ action_id = self._allocate_generated_id(preferred_id)
493
531
  self._action_index += 1
494
532
 
495
533
  # Pass agents/tools to specialized executors
@@ -574,7 +612,7 @@ class DeclarativeWorkflowBuilder:
574
612
  evaluator = IfConditionEvaluatorExecutor(
575
613
  action_def,
576
614
  condition_expr,
577
- id=f"{action_id}_eval",
615
+ id=self._allocate_generated_id(f"{action_id}_eval"),
578
616
  )
579
617
  self._executors[evaluator.id] = evaluator
580
618
 
@@ -588,7 +626,9 @@ class DeclarativeWorkflowBuilder:
588
626
  else_passthrough = None
589
627
  if not else_entry:
590
628
  # No else branch - create a passthrough for continuation when condition is false
591
- else_passthrough = JoinExecutor({"kind": "ElsePassthrough"}, id=f"{action_id}_else_pass")
629
+ else_passthrough = JoinExecutor(
630
+ {"kind": "ElsePassthrough"}, id=self._allocate_generated_id(f"{action_id}_else_pass")
631
+ )
592
632
  self._executors[else_passthrough.id] = else_passthrough
593
633
 
594
634
  # Wire evaluator to branches with conditions that check ConditionResult.branch_index
@@ -673,7 +713,7 @@ class DeclarativeWorkflowBuilder:
673
713
  evaluator: DeclarativeActionExecutor = ConditionGroupEvaluatorExecutor(
674
714
  action_def,
675
715
  conditions,
676
- id=f"{action_id}_eval",
716
+ id=self._allocate_generated_id(f"{action_id}_eval"),
677
717
  )
678
718
 
679
719
  self._executors[evaluator.id] = evaluator
@@ -708,7 +748,9 @@ class DeclarativeWorkflowBuilder:
708
748
  else:
709
749
  # No else actions - create a passthrough for the "no match" case
710
750
  # This allows the workflow to continue to the next action when no condition matches
711
- default_passthrough = JoinExecutor({"kind": "DefaultPassthrough"}, id=f"{action_id}_default")
751
+ default_passthrough = JoinExecutor(
752
+ {"kind": "DefaultPassthrough"}, id=self._allocate_generated_id(f"{action_id}_default")
753
+ )
712
754
  self._executors[default_passthrough.id] = default_passthrough
713
755
  branch_exits.append(default_passthrough)
714
756
 
@@ -781,15 +823,17 @@ class DeclarativeWorkflowBuilder:
781
823
  self._action_index += 1
782
824
 
783
825
  # Create foreach init executor
784
- init_executor = ForeachInitExecutor(action_def, id=f"{action_id}_init")
826
+ init_executor = ForeachInitExecutor(action_def, id=self._allocate_generated_id(f"{action_id}_init"))
785
827
  self._executors[init_executor.id] = init_executor
786
828
 
787
829
  # Create foreach next executor (for advancing to next item)
788
- next_executor = ForeachNextExecutor(action_def, init_executor.id, id=f"{action_id}_next")
830
+ next_executor = ForeachNextExecutor(
831
+ action_def, init_executor.id, id=self._allocate_generated_id(f"{action_id}_next")
832
+ )
789
833
  self._executors[next_executor.id] = next_executor
790
834
 
791
835
  # Create join node for loop exit
792
- join_executor = JoinExecutor({"kind": "Join"}, id=f"{action_id}_exit")
836
+ join_executor = JoinExecutor({"kind": "Join"}, id=self._allocate_generated_id(f"{action_id}_exit"))
793
837
  self._executors[join_executor.id] = join_executor
794
838
 
795
839
  # Create loop body
@@ -865,7 +909,7 @@ class DeclarativeWorkflowBuilder:
865
909
  return None
866
910
 
867
911
  # Create a pass-through executor for the goto
868
- action_id = action_def.get("id") or f"goto_{target_id}_{self._action_index}"
912
+ action_id = action_def.get("id") or self._allocate_generated_id(f"goto_{target_id}_{self._action_index}")
869
913
  self._action_index += 1
870
914
 
871
915
  # Use JoinExecutor as a simple pass-through node
@@ -892,7 +936,7 @@ class DeclarativeWorkflowBuilder:
892
936
 
893
937
  if parent_context and "loop_next_executor" in parent_context:
894
938
  loop_next = parent_context["loop_next_executor"]
895
- action_id = action_def.get("id") or f"Break_{self._action_index}"
939
+ action_id = action_def.get("id") or self._allocate_generated_id(f"Break_{self._action_index}")
896
940
  self._action_index += 1
897
941
 
898
942
  executor = BreakLoopExecutor(action_def, loop_next.id, id=action_id)
@@ -920,7 +964,7 @@ class DeclarativeWorkflowBuilder:
920
964
 
921
965
  if parent_context and "loop_next_executor" in parent_context:
922
966
  loop_next = parent_context["loop_next_executor"]
923
- action_id = action_def.get("id") or f"Continue_{self._action_index}"
967
+ action_id = action_def.get("id") or self._allocate_generated_id(f"Continue_{self._action_index}")
924
968
  self._action_index += 1
925
969
 
926
970
  executor = ContinueLoopExecutor(action_def, loop_next.id, id=action_id)
@@ -6,6 +6,7 @@ These executors handle simple actions like SetValue, SendActivity, etc.
6
6
  Each action becomes a node in the workflow graph.
7
7
  """
8
8
 
9
+ import math
9
10
  import uuid
10
11
  from collections.abc import Mapping
11
12
  from typing import Any, cast
@@ -52,7 +53,8 @@ class SetValueExecutor(DeclarativeActionExecutor):
52
53
  """Handle the SetValue action."""
53
54
  state = await self._ensure_state_initialized(ctx, trigger)
54
55
 
55
- path = self._action_def.get("path")
56
+ # The builder accepts 'variable' in place of 'path' for SetValue
57
+ path = self._action_def.get("path") or _get_variable_path(self._action_def)
56
58
  value = self._action_def.get("value")
57
59
 
58
60
  if path:
@@ -517,6 +519,9 @@ class ParseValueExecutor(DeclarativeActionExecutor):
517
519
  try:
518
520
  if "." in value:
519
521
  return float(value)
522
+ if "e" in value.lower():
523
+ parsed_value = float(value)
524
+ return parsed_value if math.isfinite(parsed_value) else 0
520
525
  return int(value)
521
526
  except ValueError:
522
527
  return 0
@@ -219,7 +219,11 @@ class ForeachNextExecutor(DeclarativeActionExecutor):
219
219
  trigger: Any,
220
220
  ctx: WorkflowContext[LoopIterationResult],
221
221
  ) -> None:
222
- """Advance to next item and send result."""
222
+ """Handle loop-control signals or advance to the next item and send the result."""
223
+ if isinstance(trigger, LoopControl):
224
+ await self.handle_loop_control(trigger, ctx)
225
+ return
226
+
223
227
  state = await self._ensure_state_initialized(ctx, trigger)
224
228
 
225
229
  loop_id = self._init_executor_id
@@ -32,7 +32,7 @@ from collections.abc import Mapping
32
32
  from dataclasses import dataclass, field
33
33
  from typing import Any
34
34
 
35
- import httpx
35
+ import httpx2 as httpx
36
36
  from agent_framework import (
37
37
  Content,
38
38
  Message,
@@ -503,10 +503,10 @@ class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
503
503
  )
504
504
  except Exception as exc:
505
505
  try:
506
- from mcp.shared.exceptions import McpError
506
+ from mcp.shared.exceptions import MCPError
507
507
  except ImportError: # pragma: no cover - mcp is a hard dep
508
508
  raise
509
- if isinstance(exc, McpError):
509
+ if isinstance(exc, MCPError):
510
510
  message = str(exc) or type(exc).__name__
511
511
  return MCPToolResult(
512
512
  outputs=[Content.from_text(f"Error: {message}")],
@@ -596,7 +596,7 @@ class WorkflowFactory:
596
596
  Args:
597
597
  name: The name to register the agent under. Must match the agent name
598
598
  referenced in InvokeAzureAgent actions.
599
- agent: The agent instance (typically a Agent or similar).
599
+ agent: The agent instance (typically an Agent or similar).
600
600
 
601
601
  Returns:
602
602
  Self for method chaining.
@@ -21,13 +21,16 @@ enforces an allowlist or DNS-rebinding-resistant policy. This split mirrors the
21
21
  from __future__ import annotations
22
22
 
23
23
  import asyncio
24
- from collections.abc import Awaitable, Callable, Mapping
24
+ import sys
25
+ import zlib
26
+ from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping
25
27
  from dataclasses import dataclass, field, replace
26
28
  from http.cookiejar import CookieJar, DefaultCookiePolicy
27
29
  from typing import Any, Protocol, runtime_checkable
28
30
  from urllib.parse import parse_qsl, quote, urlencode, urlsplit
29
31
 
30
32
  import httpx
33
+ from httpx._client import ACCEPT_ENCODING
31
34
 
32
35
  __all__ = [
33
36
  "DefaultHttpRequestHandler",
@@ -142,6 +145,16 @@ class DefaultHttpRequestHandler:
142
145
  Explicit outbound ``Cookie`` headers and response ``Set-Cookie`` headers are preserved;
143
146
  the owned-client policy only prevents automatic cookie persistence.
144
147
 
148
+ Response bodies are limited to ``max_response_body_bytes`` (10 MiB by default),
149
+ both on the wire and after each gzip/deflate decoding stage. Other content encodings
150
+ are rejected. The limit also applies to redirect and authentication responses.
151
+ Caller-supplied response hooks must not eagerly read response bodies.
152
+
153
+ Keyword Args:
154
+ client: Optional caller-owned HTTP client.
155
+ client_provider: Optional per-request client provider.
156
+ max_response_body_bytes: Positive wire and decoded response-body limit in bytes.
157
+
145
158
  .. warning::
146
159
 
147
160
  This handler performs **no** URL filtering or SSRF protection. Wrap or
@@ -153,7 +166,13 @@ class DefaultHttpRequestHandler:
153
166
  *,
154
167
  client: httpx.AsyncClient | None = None,
155
168
  client_provider: ClientProvider | None = None,
169
+ max_response_body_bytes: int = 10 * 1024 * 1024,
156
170
  ) -> None:
171
+ if isinstance(max_response_body_bytes, bool) or not isinstance(max_response_body_bytes, int):
172
+ raise ValueError("max_response_body_bytes must be a positive integer.")
173
+ if max_response_body_bytes <= 0:
174
+ raise ValueError("max_response_body_bytes must be a positive integer.")
175
+ self._max_response_body_bytes = max_response_body_bytes
157
176
  self._owned_client: httpx.AsyncClient | None = None
158
177
  self._caller_client = client
159
178
  self._client_provider = client_provider
@@ -193,6 +212,8 @@ class DefaultHttpRequestHandler:
193
212
  timeout = httpx.USE_CLIENT_DEFAULT
194
213
 
195
214
  headers = dict(info.headers)
215
+ if not _has_header(headers, "accept-encoding") and client.headers.get("accept-encoding") == ACCEPT_ENCODING:
216
+ headers["Accept-Encoding"] = "gzip, deflate"
196
217
  content: bytes | str | None = None
197
218
  if info.body is not None:
198
219
  content = info.body
@@ -224,7 +245,19 @@ class DefaultHttpRequestHandler:
224
245
  )
225
246
  request.url = url
226
247
 
227
- response = await client.send(request)
248
+ auth = client.auth
249
+ if auth is None:
250
+ auth = httpx.BasicAuth(url.username, url.password) if url.username or url.password else httpx.Auth()
251
+ response = await client.send(
252
+ request,
253
+ stream=True,
254
+ follow_redirects=False,
255
+ auth=_ResponseLimitAuth(
256
+ auth,
257
+ limit=self._max_response_body_bytes,
258
+ follow_redirects=client.follow_redirects,
259
+ ),
260
+ )
228
261
 
229
262
  # Preserve multi-value headers (e.g. multiple Set-Cookie) as list[str].
230
263
  # Normalize names to lowercase so lookups are consistent and case
@@ -296,3 +329,130 @@ def _has_header(headers: Mapping[str, str], name: str) -> bool:
296
329
  """Case-insensitive header presence check."""
297
330
  needle = name.lower()
298
331
  return any(key.lower() == needle for key in headers)
332
+
333
+
334
+ class _ResponseLimitAuth(httpx.Auth):
335
+ """Read every response within bounds before HTTPX can buffer intermediate bodies."""
336
+
337
+ def __init__(self, auth: httpx.Auth, *, limit: int, follow_redirects: bool) -> None:
338
+ self._auth = auth
339
+ self._limit = limit
340
+ self._follow_redirects = follow_redirects
341
+
342
+ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
343
+ flow = self._auth.async_auth_flow(request)
344
+ try:
345
+ request = await flow.__anext__()
346
+ while True:
347
+ response = yield request
348
+ await _read_bounded_response(response, self._limit)
349
+ if self._follow_redirects and response.next_request is not None:
350
+ request = response.next_request
351
+ continue
352
+ try:
353
+ request = await flow.asend(response)
354
+ except StopAsyncIteration:
355
+ return
356
+ finally:
357
+ await flow.aclose()
358
+
359
+
360
+ async def _read_bounded_response(response: httpx.Response, limit: int) -> None:
361
+ """Cache only bounded decoded content, preserving HTTPX text and auth behavior."""
362
+ try:
363
+ if response.is_stream_consumed:
364
+ if len(response.content) > limit:
365
+ raise httpx.StreamError("HTTP response body exceeds the configured limit.")
366
+ return
367
+
368
+ encodings = [
369
+ encoding.strip().lower()
370
+ for encoding in response.headers.get("content-encoding", "").split(",")
371
+ if encoding.strip().lower() != "identity" and encoding.strip()
372
+ ]
373
+ if (
374
+ response.request.method == "HEAD"
375
+ or 100 <= response.status_code < 200
376
+ or response.status_code in {204, 205, 304}
377
+ or (response.request.method == "CONNECT" and 200 <= response.status_code < 300)
378
+ ):
379
+ encodings = []
380
+ if len(encodings) > 4:
381
+ raise httpx.DecodingError("HTTP response has too many content encoding stages.", request=response.request)
382
+ decoders = [_BoundedContentDecoder(encoding, limit) for encoding in reversed(encodings)]
383
+ body = bytearray()
384
+ received = 0
385
+ async for chunk in response.aiter_raw():
386
+ received += len(chunk)
387
+ if received > limit:
388
+ raise httpx.StreamError("HTTP response body exceeds the configured limit.")
389
+ for decoder in decoders:
390
+ chunk = decoder.decode(chunk)
391
+ if len(body) + len(chunk) > limit:
392
+ raise httpx.StreamError("HTTP response body exceeds the configured limit.")
393
+ body.extend(chunk)
394
+ for index, decoder in enumerate(decoders):
395
+ chunk = decoder.finish()
396
+ for downstream in decoders[index + 1 :]:
397
+ chunk = downstream.decode(chunk)
398
+ if len(body) + len(chunk) > limit:
399
+ raise httpx.StreamError("HTTP response body exceeds the configured limit.")
400
+ body.extend(chunk)
401
+ # HTTPX rereads intermediate responses during auth/redirect handling. Populate
402
+ # its content cache so those reads cannot trigger automatic decoding again.
403
+ response._content = bytes(body) # pyright: ignore[reportPrivateUsage]
404
+ finally:
405
+ await response.aclose()
406
+
407
+
408
+ class _BoundedContentDecoder:
409
+ def __init__(self, encoding: str, limit: int) -> None:
410
+ if encoding not in {"gzip", "deflate"}:
411
+ raise httpx.DecodingError(f"Unsupported HTTP response content encoding: {encoding}.")
412
+ self._limit = limit
413
+ self._decoded = 0
414
+ self._input = bytearray()
415
+ self._decoder = zlib.decompressobj(zlib.MAX_WBITS | 16) if encoding == "gzip" else None
416
+
417
+ def decode(self, data: bytes) -> bytes:
418
+ if self._decoder is None:
419
+ # Raw deflate can share a wrapped stream's prefix. Retain bounded input
420
+ # until finalization so a failed candidate never reaches downstream stages.
421
+ if len(self._input) + len(data) > self._limit:
422
+ raise httpx.StreamError("HTTP response body exceeds the configured limit.")
423
+ self._input.extend(data)
424
+ return b""
425
+ try:
426
+ decoded = self._decoder.decompress(data, min(self._limit - self._decoded + 1, sys.maxsize))
427
+ except zlib.error as exc:
428
+ raise httpx.DecodingError(str(exc)) from exc
429
+ self._decoded += len(decoded)
430
+ if self._decoded > self._limit:
431
+ raise httpx.StreamError("HTTP response body exceeds the configured limit.")
432
+ if self._decoder.unused_data:
433
+ raise httpx.DecodingError("Unexpected data after HTTP response content encoding.")
434
+ return decoded
435
+
436
+ def finish(self) -> bytes:
437
+ if self._decoder is not None:
438
+ if not self._decoder.eof:
439
+ raise httpx.DecodingError("Incomplete HTTP response content encoding.")
440
+ return b""
441
+
442
+ data = bytes(self._input)
443
+ self._input.clear()
444
+ failures: list[httpx.DecodingError | httpx.StreamError] = []
445
+ for mode in (zlib.MAX_WBITS, -zlib.MAX_WBITS):
446
+ self._decoder = zlib.decompressobj(mode)
447
+ self._decoded = 0
448
+ try:
449
+ decoded = self.decode(data)
450
+ if not self._decoder.eof:
451
+ raise httpx.DecodingError("Incomplete HTTP response content encoding.")
452
+ return decoded
453
+ except (httpx.DecodingError, httpx.StreamError) as exc:
454
+ failures.append(exc)
455
+ for error in failures:
456
+ if isinstance(error, httpx.StreamError):
457
+ raise error
458
+ raise failures[-1]
@@ -27,6 +27,7 @@ must trust the MCP server they invoke.
27
27
  from __future__ import annotations
28
28
 
29
29
  import asyncio
30
+ import contextlib
30
31
  import hashlib
31
32
  import json
32
33
  import logging
@@ -36,7 +37,7 @@ from contextvars import ContextVar, Token
36
37
  from dataclasses import dataclass, field
37
38
  from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast, runtime_checkable
38
39
 
39
- import httpx
40
+ import httpx2 as httpx
40
41
 
41
42
  if TYPE_CHECKING:
42
43
  from agent_framework import Content
@@ -345,10 +346,10 @@ class DefaultMCPToolHandler:
345
346
  # Be defensive about MCP errors that may bubble up without being
346
347
  # wrapped in ToolExecutionException by custom parsers.
347
348
  try:
348
- from mcp.shared.exceptions import McpError
349
+ from mcp.shared.exceptions import MCPError
349
350
  except ImportError: # pragma: no cover - mcp is a hard dep but stay defensive
350
351
  raise
351
- if isinstance(exc, McpError):
352
+ if isinstance(exc, MCPError):
352
353
  message = str(exc) or type(exc).__name__
353
354
  return MCPToolResult(
354
355
  outputs=[Content.from_text(f"Error: {message}")],
@@ -369,7 +370,7 @@ class DefaultMCPToolHandler:
369
370
  async def _invoke_list_tools(entry: _CacheEntry) -> MCPToolResult:
370
371
  """Handle the reserved :attr:`LIST_TOOLS_TOOL_NAME` invocation.
371
372
 
372
- Pages through ``session.list_tools()`` (mirroring the pagination loop
373
+ Pages through the normalized MCP connection (mirroring the pagination loop
373
374
  in :meth:`agent_framework.MCPTool.load_tools`) and serialises the
374
375
  full catalog as a single ``TextContent`` containing JSON of shape
375
376
  ``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
@@ -383,8 +384,16 @@ class DefaultMCPToolHandler:
383
384
  """
384
385
  from agent_framework import Content
385
386
 
386
- session = getattr(entry.tool, "session", None)
387
- if session is None:
387
+ require_connection: Any = getattr(entry.tool, "_require_connection", None)
388
+ connection: Any | None = None
389
+ session: Any | None = None
390
+ if callable(require_connection):
391
+ with contextlib.suppress(RuntimeError):
392
+ connection = require_connection()
393
+ else:
394
+ session = getattr(entry.tool, "session", None)
395
+
396
+ if connection is None and session is None:
388
397
  message = "MCP session is not connected; cannot list tools."
389
398
  return MCPToolResult(
390
399
  outputs=[Content.from_text(f"Error: {message}")],
@@ -398,9 +407,14 @@ class DefaultMCPToolHandler:
398
407
  collected: list[Any] = []
399
408
  params: mcp_types.PaginatedRequestParams | None = None
400
409
  while True:
401
- tool_list = await session.list_tools(params=params)
410
+ if connection is not None:
411
+ tool_list = await connection.list_tools_page(params)
412
+ elif session is not None:
413
+ tool_list = await session.list_tools(params=params)
414
+ else: # pragma: no cover - guarded before entering the loop
415
+ raise RuntimeError("MCP connection became unavailable while listing tools.")
402
416
  collected.extend(tool_list.tools)
403
- next_cursor = getattr(tool_list, "nextCursor", None)
417
+ next_cursor = getattr(tool_list, "next_cursor", getattr(tool_list, "nextCursor", None))
404
418
  if not next_cursor:
405
419
  break
406
420
  params = mcp_types.PaginatedRequestParams(cursor=next_cursor)
@@ -410,8 +424,8 @@ class DefaultMCPToolHandler:
410
424
  {
411
425
  "name": tool.name,
412
426
  "description": tool.description,
413
- "inputSchema": tool.inputSchema,
414
- "outputSchema": tool.outputSchema,
427
+ "inputSchema": getattr(tool, "input_schema", getattr(tool, "inputSchema", None)),
428
+ "outputSchema": getattr(tool, "output_schema", getattr(tool, "outputSchema", None)),
415
429
  }
416
430
  for tool in collected
417
431
  ],
@@ -12,9 +12,156 @@ or registered with the PowerFx engine when it is available.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ from collections.abc import Iterator
15
16
  from typing import Any, cast
16
17
 
17
18
 
19
+ def _skip_powerfx_opaque_token(formula: str, start: int) -> int:
20
+ """Return the end of an ordinary quoted token or comment, or start if neither."""
21
+ quote = formula[start]
22
+ if quote in ('"', "'"):
23
+ pos = start + 1
24
+ while pos < len(formula):
25
+ if formula[pos] == quote:
26
+ if pos + 1 < len(formula) and formula[pos + 1] == quote:
27
+ pos += 2
28
+ continue
29
+ return pos + 1
30
+ pos += 1
31
+ return pos
32
+ if formula.startswith("//", start):
33
+ pos = start + 2
34
+ while pos < len(formula) and formula[pos] not in "\r\n":
35
+ pos += 1
36
+ return pos
37
+ if formula.startswith("/*", start):
38
+ end = formula.find("*/", start + 2)
39
+ # An unterminated /* is not a complete comment: leave it as code so a
40
+ # trailing one is not mistaken for trivia following a complete call.
41
+ return start if end == -1 else end + 2
42
+ return start
43
+
44
+
45
+ def _iter_powerfx_expression_indices(formula: str) -> Iterator[int]:
46
+ """Yield code positions, excluding literals/comments but including interpolation expressions."""
47
+ # None denotes interpolated text; integers track record braces in expression sections.
48
+ scopes: list[int | None] = [0]
49
+ cursor = 0
50
+ while cursor < len(formula):
51
+ depth = scopes[-1]
52
+ char = formula[cursor]
53
+ if depth is None:
54
+ if formula.startswith(('""', "{{", "}}"), cursor):
55
+ cursor += 2
56
+ continue
57
+ if char == "{":
58
+ scopes.append(0)
59
+ elif char == '"':
60
+ scopes.pop()
61
+ cursor += 1
62
+ continue
63
+
64
+ if formula.startswith('$"', cursor):
65
+ scopes.append(None)
66
+ cursor += 2
67
+ continue
68
+
69
+ token_end = _skip_powerfx_opaque_token(formula, cursor)
70
+ if token_end != cursor:
71
+ cursor = token_end
72
+ continue
73
+
74
+ if char == "{":
75
+ scopes[-1] = depth + 1
76
+ elif char == "}":
77
+ if depth > 0:
78
+ scopes[-1] = depth - 1
79
+ elif len(scopes) > 1:
80
+ scopes.pop()
81
+ cursor += 1
82
+ continue
83
+
84
+ yield cursor
85
+ cursor += 1
86
+
87
+
88
+ def _after_trailing_trivia(formula: str, start: int) -> int:
89
+ """Return the index after trailing whitespace and complete comments.
90
+
91
+ Unlike the expression walker, this never skips a string literal: a quoted
92
+ token after the call's own closing paren is trailing code, not trivia, so it
93
+ stops the scan and leaves the formula to PowerFx.
94
+ """
95
+ index = start
96
+ while index < len(formula):
97
+ if formula[index].isspace():
98
+ index += 1
99
+ continue
100
+ if formula.startswith(("//", "/*"), index):
101
+ token_end = _skip_powerfx_opaque_token(formula, index)
102
+ if token_end == index:
103
+ # Unterminated /* is not a complete comment, so real code follows.
104
+ break
105
+ index = token_end
106
+ continue
107
+ break
108
+ return index
109
+
110
+
111
+ def whole_call_args(formula: str, *names: str) -> str | None:
112
+ """Return the argument text when ``formula`` is exactly one ``Name(...)`` call.
113
+
114
+ A custom-function handler must not fire for a formula that merely *starts*
115
+ with its name: the closing parenthesis has to be the call's own and nothing
116
+ but trivia may follow it. Otherwise a trailing operator is swallowed into the
117
+ argument list and the formula is evaluated as something the author never
118
+ wrote, instead of being left to PowerFx.
119
+
120
+ Parentheses inside string literals and comments are data, not delimiters, so
121
+ the scan walks the formula with the shared PowerFx expression walker: it
122
+ skips those tokens whole and counts only parentheses that are real code,
123
+ including those inside an interpolated string's ``{...}`` expression sections.
124
+
125
+ Args:
126
+ formula: The stripped formula to inspect.
127
+ *names: Candidate function names, matched case-sensitively.
128
+
129
+ Returns:
130
+ The text between the call's parentheses - an empty string for an
131
+ argument-less call - or None when ``formula`` is not a single complete
132
+ call to one of ``names``. Callers that require an argument check the
133
+ result for emptiness so the call still falls through to PowerFx, which
134
+ reports the arity error.
135
+ """
136
+ for name in names:
137
+ prefix = f"{name}("
138
+ if not formula.startswith(prefix):
139
+ continue
140
+ positions = _iter_powerfx_expression_indices(formula)
141
+ depth = 0
142
+ close_index = -1
143
+ for index in positions:
144
+ char = formula[index]
145
+ if char == "(":
146
+ depth += 1
147
+ elif char == ")":
148
+ depth -= 1
149
+ if depth == 0:
150
+ close_index = index
151
+ break
152
+ if close_index == -1:
153
+ # Unbalanced: the call never closes, so it is not a whole call.
154
+ return None
155
+ # Only whitespace and complete comments may follow the call's own closing
156
+ # paren. The suffix scan deliberately does not reuse the literal-skipping
157
+ # walker: a trailing string literal (or any other code) there means the
158
+ # formula is a larger expression the caller must not claim.
159
+ if _after_trailing_trivia(formula, close_index + 1) != len(formula):
160
+ return None
161
+ return formula[len(prefix) : close_index]
162
+ return None
163
+
164
+
18
165
  def message_text(messages: Any) -> str:
19
166
  """Extract text content from a message or list of messages.
20
167
 
@@ -441,7 +441,7 @@ class WorkflowState:
441
441
  Returns:
442
442
  The evaluated result
443
443
  """
444
- from ._powerfx_functions import CUSTOM_FUNCTIONS
444
+ from ._powerfx_functions import CUSTOM_FUNCTIONS, whole_call_args
445
445
 
446
446
  formula = formula.strip()
447
447
 
@@ -452,15 +452,15 @@ class WorkflowState:
452
452
  return not bool(result)
453
453
 
454
454
  # Handle Not() function
455
- if formula.startswith("Not(") and formula.endswith(")"):
456
- inner = formula[4:-1].strip()
457
- result = self._eval_simple(inner)
455
+ not_args = whole_call_args(formula, "Not")
456
+ if not_args is not None:
457
+ result = self._eval_simple(not_args.strip())
458
458
  return not bool(result)
459
459
 
460
460
  # Handle function calls
461
461
  for func_name, func in CUSTOM_FUNCTIONS.items():
462
- if formula.startswith(f"{func_name}(") and formula.endswith(")"):
463
- args_str = formula[len(func_name) + 1 : -1]
462
+ args_str = whole_call_args(formula, func_name)
463
+ if args_str is not None:
464
464
  # Simple argument parsing (doesn't handle nested calls well)
465
465
  args = self._parse_function_args(args_str)
466
466
  evaluated_args = [self._eval_simple(arg) if isinstance(arg, str) else arg for arg in args]
@@ -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.2.1"
7
+ version = "1.4.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,8 +22,9 @@ classifiers = [
22
22
  "Typing :: Typed",
23
23
  ]
24
24
  dependencies = [
25
- "agent-framework-core>=1.20.0,<2",
26
- "httpx>=0.27,<1",
25
+ "agent-framework-core>=1.23.0,<2",
26
+ "httpx>=0.27,<0.29",
27
+ "httpx2>=2.0.0,<3",
27
28
  "powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
28
29
  "pyyaml>=6.0,<7.0",
29
30
  ]
@@ -103,5 +104,5 @@ help = "Run the default unit test suite for this package."
103
104
  cmd = 'pytest -m "not integration" --cov=agent_framework_declarative --cov-report=term-missing:skip-covered tests'
104
105
 
105
106
  [build-system]
106
- requires = ["flit-core >= 3.11,<4.0"]
107
+ requires = ["flit-core>=3.11,<5.0"]
107
108
  build-backend = "flit_core.buildapi"