agent-framework-declarative 1.2.1__tar.gz → 1.3.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.3.0}/PKG-INFO +34 -3
  2. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/README.md +30 -0
  3. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_loader.py +13 -12
  4. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_declarative_base.py +7 -11
  5. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_declarative_builder.py +56 -12
  6. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_executors_basic.py +4 -0
  7. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_executors_control_flow.py +5 -1
  8. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_factory.py +1 -1
  9. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_http_handler.py +162 -2
  10. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/pyproject.toml +3 -3
  11. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/LICENSE +0 -0
  12. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/__init__.py +0 -0
  13. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_feature_usage.py +0 -0
  14. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_models.py +0 -0
  15. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/__init__.py +0 -0
  16. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_errors.py +0 -0
  17. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_executors_agents.py +0 -0
  18. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_executors_external_input.py +0 -0
  19. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_executors_http.py +0 -0
  20. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_executors_mcp.py +0 -0
  21. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_executors_tools.py +0 -0
  22. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_mcp_handler.py +0 -0
  23. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_powerfx_functions.py +0 -0
  24. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_powerfx_limits.py +0 -0
  25. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_state.py +0 -0
  26. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_state_path.py +0 -0
  27. {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.3.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.3.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
@@ -16,13 +16,14 @@ Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Typing :: Typed
17
17
  License-File: LICENSE
18
18
  Requires-Dist: agent-framework-core>=1.20.0,<2
19
- Requires-Dist: httpx>=0.27,<1
19
+ Requires-Dist: httpx>=0.27,<0.29
20
20
  Requires-Dist: powerfx>=0.0.32,<0.0.35; python_version < '3.14'
21
21
  Requires-Dist: pyyaml>=6.0,<7.0
22
22
  Project-URL: homepage, https://aka.ms/agent-framework
23
23
  Project-URL: issues, https://github.com/microsoft/agent-framework/issues
24
24
  Project-URL: release_notes, https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true
25
25
  Project-URL: source, https://github.com/microsoft/agent-framework/tree/main/python
26
+ Import-Name: agent_framework_declarative
26
27
 
27
28
  # Get Started with Microsoft Agent Framework Declarative
28
29
 
@@ -32,6 +33,11 @@ Please install this package via pip:
32
33
  pip install agent-framework-declarative
33
34
  ```
34
35
 
36
+ The package currently advertises Python 3.10–3.13 support. Python 3.14 and
37
+ 3.15 can install the package, but Power Fx expressions are unavailable because
38
+ the `powerfx` dependency requires a `pythonnet` build that does not support
39
+ those Python versions.
40
+
35
41
  ## Release stage
36
42
 
37
43
  This package ships at two different stability levels:
@@ -66,6 +72,31 @@ Explicit `Cookie` request headers remain supported. Response `Set-Cookie` header
66
72
  remain available in `HttpRequestResult.headers`; disabling persistence does not redact
67
73
  the response.
68
74
 
75
+ ## HTTP response body limits
76
+
77
+ `DefaultHttpRequestHandler` limits response bodies to **10 MiB** by default.
78
+ Configure a positive integer `max_response_body_bytes` on the handler when a host
79
+ requires a different bound. The bound applies to received bytes and each decoded
80
+ stage, including intermediate redirect and authentication responses. Responses
81
+ above the bound raise `httpx.StreamError` before text conversion or workflow
82
+ assignment, and their streams are closed without closing caller-owned clients.
83
+
84
+ The handler restricts HTTPX's generated `Accept-Encoding` default to `gzip, deflate`
85
+ and supports identity, gzip, and deflate
86
+ responses, including up to four encoding stages. Unsupported encodings,
87
+ incomplete encoded bodies, and trailing encoded data raise `httpx.DecodingError`.
88
+ Empty encoded responses are validated unless the response is bodyless (HEAD,
89
+ 1xx, 204, 205, 304, or a successful CONNECT response). Deflate input is retained
90
+ within the same bound until its wrapped or raw format can be validated.
91
+ Explicit request `Accept-Encoding` headers and client values that differ from
92
+ HTTPX's generated default are preserved but do not enable additional decoders.
93
+ Caller-supplied response hooks and transports must not
94
+ buffer response bodies before the handler applies its limit.
95
+
96
+ HTTPX support is limited to the 0.27 and 0.28 series because the bounded
97
+ intermediate-response cache and generated default-header integration depend
98
+ on HTTPX internals. Compatibility tests cover HTTPX 0.27.0 and 0.28.1.
99
+
69
100
  ## MCP handler session lifetime
70
101
 
71
102
  `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:
@@ -734,14 +734,14 @@ class DeclarativeWorkflowState:
734
734
  evaluated_args: list[str] = []
735
735
  for arg in args:
736
736
  arg = arg.strip()
737
- if arg.startswith('"') and arg.endswith('"'):
737
+ if re.fullmatch(r'"(?:[^"]|"")*"', arg):
738
738
  # String literal
739
- evaluated_args.append(arg[1:-1])
739
+ evaluated_args.append(arg[1:-1].replace('""', '"'))
740
740
  elif arg.startswith("'") and arg.endswith("'"):
741
741
  # Single-quoted string literal
742
742
  evaluated_args.append(arg[1:-1])
743
743
  else:
744
- # Variable reference - evaluate it
744
+ # Variable reference or expression - evaluate it
745
745
  result = self.eval(f"={arg}")
746
746
  evaluated_args.append(str(result) if result is not None else "")
747
747
  return "".join(evaluated_args)
@@ -983,8 +983,9 @@ class DeclarativeWorkflowState:
983
983
  Captures brace-delimited tokens whose root segment is an identifier
984
984
  (``[A-Za-z][A-Za-z0-9_]*``) followed by zero or more ``.`` separated
985
985
  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.
986
+ tokens are replaced with the empty string. Substituted values remain
987
+ literal and are not interpolated again. Tokens that do not look like
988
+ state paths (e.g. ``{foo-bar}``, ``{Ctrl+C}``) are left literal.
988
989
 
989
990
  Args:
990
991
  text: Text that may contain {Variable.Path} references
@@ -1004,12 +1005,7 @@ class DeclarativeWorkflowState:
1004
1005
  # per-segment safety on attribute traversal.
1005
1006
  pattern = r"\{([A-Za-z][A-Za-z0-9_]*(?:\.[^{}\s.]+)*)\}"
1006
1007
 
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
1008
+ return re.sub(pattern, replace_var, text)
1013
1009
 
1014
1010
 
1015
1011
  # 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
@@ -517,6 +518,9 @@ class ParseValueExecutor(DeclarativeActionExecutor):
517
518
  try:
518
519
  if "." in value:
519
520
  return float(value)
521
+ if "e" in value.lower():
522
+ parsed_value = float(value)
523
+ return parsed_value if math.isfinite(parsed_value) else 0
520
524
  return int(value)
521
525
  except ValueError:
522
526
  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
@@ -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]
@@ -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.3.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"
@@ -23,7 +23,7 @@ classifiers = [
23
23
  ]
24
24
  dependencies = [
25
25
  "agent-framework-core>=1.20.0,<2",
26
- "httpx>=0.27,<1",
26
+ "httpx>=0.27,<0.29",
27
27
  "powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
28
28
  "pyyaml>=6.0,<7.0",
29
29
  ]
@@ -103,5 +103,5 @@ help = "Run the default unit test suite for this package."
103
103
  cmd = 'pytest -m "not integration" --cov=agent_framework_declarative --cov-report=term-missing:skip-covered tests'
104
104
 
105
105
  [build-system]
106
- requires = ["flit-core >= 3.11,<4.0"]
106
+ requires = ["flit-core>=3.11,<5.0"]
107
107
  build-backend = "flit_core.buildapi"