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.
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/PKG-INFO +36 -4
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/README.md +30 -0
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_loader.py +13 -12
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_declarative_base.py +24 -91
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_declarative_builder.py +56 -12
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_executors_basic.py +6 -1
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_executors_control_flow.py +5 -1
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_executors_mcp.py +3 -3
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_factory.py +1 -1
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_http_handler.py +162 -2
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_mcp_handler.py +24 -10
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_powerfx_functions.py +147 -0
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_state.py +6 -6
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/pyproject.toml +5 -4
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/LICENSE +0 -0
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/__init__.py +0 -0
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_feature_usage.py +0 -0
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_models.py +0 -0
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/__init__.py +0 -0
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_errors.py +0 -0
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_executors_agents.py +0 -0
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_executors_external_input.py +0 -0
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_executors_http.py +0 -0
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_executors_tools.py +0 -0
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_powerfx_limits.py +0 -0
- {agent_framework_declarative-1.2.1 → agent_framework_declarative-1.4.0}/agent_framework_declarative/_workflows/_state_path.py +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: agent-framework-declarative
|
|
3
|
-
Version: 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.
|
|
19
|
-
Requires-Dist: httpx>=0.27,<
|
|
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
|
|
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:
|
|
@@ -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
|
-
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
|
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
|
-
|
|
751
|
-
if
|
|
752
|
-
inner_expr =
|
|
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
|
-
|
|
759
|
-
if
|
|
760
|
-
inner_expr =
|
|
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
|
-
|
|
766
|
-
if
|
|
767
|
-
inner_expr =
|
|
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.
|
|
987
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
|
@@ -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
|
|
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
|
-
"""
|
|
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
|
|
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,
|
|
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
|
|
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]
|
|
@@ -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
|
|
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,
|
|
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
|
|
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
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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
|
|
414
|
-
"outputSchema": tool
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
result = self._eval_simple(
|
|
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
|
-
|
|
463
|
-
|
|
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.
|
|
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.
|
|
26
|
-
"httpx>=0.27,<
|
|
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
|
|
107
|
+
requires = ["flit-core>=3.11,<5.0"]
|
|
107
108
|
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
|