agent-framework-declarative 1.2.0__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.
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/PKG-INFO +34 -3
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/README.md +30 -0
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_loader.py +13 -12
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_declarative_base.py +7 -11
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_declarative_builder.py +56 -12
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_executors_basic.py +4 -0
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_executors_control_flow.py +5 -1
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_factory.py +1 -1
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_http_handler.py +162 -2
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_powerfx_functions.py +3 -1
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/pyproject.toml +3 -3
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/LICENSE +0 -0
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/__init__.py +0 -0
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_feature_usage.py +0 -0
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_models.py +0 -0
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/__init__.py +0 -0
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_errors.py +0 -0
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_executors_agents.py +0 -0
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_executors_external_input.py +0 -0
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_executors_http.py +0 -0
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_executors_mcp.py +0 -0
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_executors_tools.py +0 -0
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_mcp_handler.py +0 -0
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_powerfx_limits.py +0 -0
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_state.py +0 -0
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/_workflows/_state_path.py +0 -0
- {agent_framework_declarative-1.2.0 → agent_framework_declarative-1.3.0}/agent_framework_declarative/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: agent-framework-declarative
|
|
3
|
-
Version: 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,<
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
746
|
-
chat_options["additional_chat_options"] =
|
|
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
|
|
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.
|
|
987
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
"""
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
@@ -455,7 +455,9 @@ def search_table(table: Any, value: Any, column: str) -> list[Any]:
|
|
|
455
455
|
return []
|
|
456
456
|
|
|
457
457
|
results: list[Any] = []
|
|
458
|
-
|
|
458
|
+
# Only Blank (None) and "" mean "no search value, return every row"; a
|
|
459
|
+
# falsy 0 or False is still the value the caller asked to match.
|
|
460
|
+
search_value = str(value).lower() if value is not None else ""
|
|
459
461
|
|
|
460
462
|
for item in cast(list[Any], table):
|
|
461
463
|
item_value: Any = None
|
|
@@ -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.
|
|
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,<
|
|
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
|
|
106
|
+
requires = ["flit-core>=3.11,<5.0"]
|
|
107
107
|
build-backend = "flit_core.buildapi"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|