agent-framework-declarative 1.0.4__tar.gz → 1.1.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.1.0/PKG-INFO +90 -0
- agent_framework_declarative-1.1.0/README.md +63 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/agent_framework_declarative/_loader.py +6 -4
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/agent_framework_declarative/_workflows/_declarative_base.py +116 -86
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/agent_framework_declarative/_workflows/_executors_agents.py +17 -5
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/agent_framework_declarative/_workflows/_executors_http.py +5 -33
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/agent_framework_declarative/_workflows/_factory.py +1 -1
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/agent_framework_declarative/_workflows/_http_handler.py +13 -4
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/agent_framework_declarative/_workflows/_mcp_handler.py +142 -51
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/pyproject.toml +3 -3
- agent_framework_declarative-1.0.4/PKG-INFO +0 -49
- agent_framework_declarative-1.0.4/README.md +0 -22
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/LICENSE +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/agent_framework_declarative/__init__.py +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/agent_framework_declarative/_feature_usage.py +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/agent_framework_declarative/_models.py +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/agent_framework_declarative/_workflows/__init__.py +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/agent_framework_declarative/_workflows/_declarative_builder.py +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/agent_framework_declarative/_workflows/_errors.py +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/agent_framework_declarative/_workflows/_executors_basic.py +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/agent_framework_declarative/_workflows/_executors_control_flow.py +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/agent_framework_declarative/_workflows/_executors_external_input.py +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/agent_framework_declarative/_workflows/_executors_mcp.py +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/agent_framework_declarative/_workflows/_executors_tools.py +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/agent_framework_declarative/_workflows/_powerfx_functions.py +0 -0
- {agent_framework_declarative-1.0.4 → agent_framework_declarative-1.1.0}/agent_framework_declarative/_workflows/_state.py +0 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-framework-declarative
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Declarative specification support for Microsoft Agent Framework.
|
|
5
|
+
Author-email: Microsoft <af-support@microsoft.com>
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: agent-framework-core>=1.19.0,<2
|
|
19
|
+
Requires-Dist: httpx>=0.27,<1
|
|
20
|
+
Requires-Dist: powerfx>=0.0.32,<0.0.35; python_version < '3.14'
|
|
21
|
+
Requires-Dist: pyyaml>=6.0,<7.0
|
|
22
|
+
Project-URL: homepage, https://aka.ms/agent-framework
|
|
23
|
+
Project-URL: issues, https://github.com/microsoft/agent-framework/issues
|
|
24
|
+
Project-URL: release_notes, https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true
|
|
25
|
+
Project-URL: source, https://github.com/microsoft/agent-framework/tree/main/python
|
|
26
|
+
|
|
27
|
+
# Get Started with Microsoft Agent Framework Declarative
|
|
28
|
+
|
|
29
|
+
Please install this package via pip:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install agent-framework-declarative
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Release stage
|
|
36
|
+
|
|
37
|
+
This package ships at two different stability levels:
|
|
38
|
+
|
|
39
|
+
- **Declarative workflows** (`WorkflowFactory`, executors, handlers, and the
|
|
40
|
+
`_workflows` surface) are **stable**.
|
|
41
|
+
- **Declarative agents** (`AgentFactory` and the YAML agent loading/parsing path:
|
|
42
|
+
`DeclarativeLoaderError`, `ProviderLookupError`, `ProviderTypeMapping`) are
|
|
43
|
+
**experimental** and may change or be removed in future versions without notice.
|
|
44
|
+
Using any of these symbols emits an `ExperimentalWarning` on first use.
|
|
45
|
+
|
|
46
|
+
## Declarative features
|
|
47
|
+
|
|
48
|
+
The declarative packages provides support for building agents based on a declarative yaml specification.
|
|
49
|
+
|
|
50
|
+
## HTTP request client ownership and cookies
|
|
51
|
+
|
|
52
|
+
**Breaking change:** The HTTP client created by `DefaultHttpRequestHandler` no longer
|
|
53
|
+
persists response cookies. The handler still creates its client lazily, reuses it across
|
|
54
|
+
requests and workflows, and closes it on `aclose()` or async context-manager exit.
|
|
55
|
+
Timeout and redirect defaults are unchanged.
|
|
56
|
+
|
|
57
|
+
Applications requiring cookies for authentication, session continuity, or load-balancer
|
|
58
|
+
affinity must supply an `httpx.AsyncClient` through `client=` or `client_provider=`.
|
|
59
|
+
Supplied and provider-returned clients retain their configuration and cookie behavior
|
|
60
|
+
and must be closed by the caller. Scope cookie-bearing clients to one authenticated
|
|
61
|
+
principal; sharing a handler across workflows does not partition a client's cookies.
|
|
62
|
+
A provider returning `None` falls back to `client=`, if supplied, then to the internally
|
|
63
|
+
owned client with the default cookie policy.
|
|
64
|
+
|
|
65
|
+
Explicit `Cookie` request headers remain supported. Response `Set-Cookie` headers
|
|
66
|
+
remain available in `HttpRequestResult.headers`; disabling persistence does not redact
|
|
67
|
+
the response.
|
|
68
|
+
|
|
69
|
+
## MCP handler session lifetime
|
|
70
|
+
|
|
71
|
+
`DefaultMCPToolHandler()` reuses MCP sessions through a bounded LRU cache.
|
|
72
|
+
When configured with `client_provider`, it instead invokes the provider and
|
|
73
|
+
creates a fresh MCP tool/session for every invocation, including `tools/list`.
|
|
74
|
+
Sessions are never reused in this mode, even if the provider returns `None`
|
|
75
|
+
or the same `httpx.AsyncClient` instance.
|
|
76
|
+
|
|
77
|
+
Each invocation closes its tool/session on success, failure, or cancellation.
|
|
78
|
+
Internally created fallback HTTP clients are also closed; caller-supplied HTTP
|
|
79
|
+
clients remain caller-owned and are never closed by the handler. Calling
|
|
80
|
+
`aclose()` rejects new invocations and waits for active provider-backed
|
|
81
|
+
invocations to finish cleaning up. Calling it from an active invocation's
|
|
82
|
+
context (including provider callbacks, inherited child tasks, and cleanup)
|
|
83
|
+
raises `RuntimeError` before changing handler state, rather than waiting on
|
|
84
|
+
itself. Close the handler outside that context or after the invocation completes.
|
|
85
|
+
|
|
86
|
+
This intentionally incurs connection and initialization overhead and does not
|
|
87
|
+
retain server session state between provider-backed invocations. Applications
|
|
88
|
+
that require shared session ownership must implement an explicitly scoped
|
|
89
|
+
custom `MCPToolHandler`; there is no provider-backed session-cache opt-in.
|
|
90
|
+
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Get Started with Microsoft Agent Framework Declarative
|
|
2
|
+
|
|
3
|
+
Please install this package via pip:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install agent-framework-declarative
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Release stage
|
|
10
|
+
|
|
11
|
+
This package ships at two different stability levels:
|
|
12
|
+
|
|
13
|
+
- **Declarative workflows** (`WorkflowFactory`, executors, handlers, and the
|
|
14
|
+
`_workflows` surface) are **stable**.
|
|
15
|
+
- **Declarative agents** (`AgentFactory` and the YAML agent loading/parsing path:
|
|
16
|
+
`DeclarativeLoaderError`, `ProviderLookupError`, `ProviderTypeMapping`) are
|
|
17
|
+
**experimental** and may change or be removed in future versions without notice.
|
|
18
|
+
Using any of these symbols emits an `ExperimentalWarning` on first use.
|
|
19
|
+
|
|
20
|
+
## Declarative features
|
|
21
|
+
|
|
22
|
+
The declarative packages provides support for building agents based on a declarative yaml specification.
|
|
23
|
+
|
|
24
|
+
## HTTP request client ownership and cookies
|
|
25
|
+
|
|
26
|
+
**Breaking change:** The HTTP client created by `DefaultHttpRequestHandler` no longer
|
|
27
|
+
persists response cookies. The handler still creates its client lazily, reuses it across
|
|
28
|
+
requests and workflows, and closes it on `aclose()` or async context-manager exit.
|
|
29
|
+
Timeout and redirect defaults are unchanged.
|
|
30
|
+
|
|
31
|
+
Applications requiring cookies for authentication, session continuity, or load-balancer
|
|
32
|
+
affinity must supply an `httpx.AsyncClient` through `client=` or `client_provider=`.
|
|
33
|
+
Supplied and provider-returned clients retain their configuration and cookie behavior
|
|
34
|
+
and must be closed by the caller. Scope cookie-bearing clients to one authenticated
|
|
35
|
+
principal; sharing a handler across workflows does not partition a client's cookies.
|
|
36
|
+
A provider returning `None` falls back to `client=`, if supplied, then to the internally
|
|
37
|
+
owned client with the default cookie policy.
|
|
38
|
+
|
|
39
|
+
Explicit `Cookie` request headers remain supported. Response `Set-Cookie` headers
|
|
40
|
+
remain available in `HttpRequestResult.headers`; disabling persistence does not redact
|
|
41
|
+
the response.
|
|
42
|
+
|
|
43
|
+
## MCP handler session lifetime
|
|
44
|
+
|
|
45
|
+
`DefaultMCPToolHandler()` reuses MCP sessions through a bounded LRU cache.
|
|
46
|
+
When configured with `client_provider`, it instead invokes the provider and
|
|
47
|
+
creates a fresh MCP tool/session for every invocation, including `tools/list`.
|
|
48
|
+
Sessions are never reused in this mode, even if the provider returns `None`
|
|
49
|
+
or the same `httpx.AsyncClient` instance.
|
|
50
|
+
|
|
51
|
+
Each invocation closes its tool/session on success, failure, or cancellation.
|
|
52
|
+
Internally created fallback HTTP clients are also closed; caller-supplied HTTP
|
|
53
|
+
clients remain caller-owned and are never closed by the handler. Calling
|
|
54
|
+
`aclose()` rejects new invocations and waits for active provider-backed
|
|
55
|
+
invocations to finish cleaning up. Calling it from an active invocation's
|
|
56
|
+
context (including provider callbacks, inherited child tasks, and cleanup)
|
|
57
|
+
raises `RuntimeError` before changing handler state, rather than waiting on
|
|
58
|
+
itself. Close the handler outside that context or after the invocation completes.
|
|
59
|
+
|
|
60
|
+
This intentionally incurs connection and initialization overhead and does not
|
|
61
|
+
retain server session state between provider-backed invocations. Applications
|
|
62
|
+
that require shared session ownership must implement an explicitly scoped
|
|
63
|
+
custom `MCPToolHandler`; there is no provider-backed session-cache opt-in.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import sys
|
|
6
7
|
from collections.abc import Callable, Mapping
|
|
7
8
|
from pathlib import Path
|
|
@@ -338,7 +339,7 @@ class AgentFactory:
|
|
|
338
339
|
yaml_path = Path(yaml_path)
|
|
339
340
|
if not yaml_path.exists():
|
|
340
341
|
raise DeclarativeLoaderError(f"YAML file not found at path: {yaml_path}")
|
|
341
|
-
with open(yaml_path) as f:
|
|
342
|
+
with open(yaml_path, encoding="utf-8") as f:
|
|
342
343
|
yaml_str = f.read()
|
|
343
344
|
return self.create_agent_from_yaml(yaml_str)
|
|
344
345
|
|
|
@@ -508,9 +509,10 @@ class AgentFactory:
|
|
|
508
509
|
"""
|
|
509
510
|
if not isinstance(yaml_path, Path):
|
|
510
511
|
yaml_path = Path(yaml_path)
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
512
|
+
try:
|
|
513
|
+
yaml_str = await asyncio.to_thread(yaml_path.read_text, encoding="utf-8")
|
|
514
|
+
except FileNotFoundError as exc:
|
|
515
|
+
raise DeclarativeLoaderError(f"YAML file not found at path: {yaml_path}") from exc
|
|
514
516
|
return await self.create_agent_from_yaml_async(yaml_str)
|
|
515
517
|
|
|
516
518
|
async def create_agent_from_yaml_async(self, yaml_str: str) -> Agent:
|
|
@@ -31,7 +31,7 @@ import os
|
|
|
31
31
|
import re
|
|
32
32
|
import sys
|
|
33
33
|
import uuid
|
|
34
|
-
from collections.abc import Mapping
|
|
34
|
+
from collections.abc import Iterator, Mapping
|
|
35
35
|
from dataclasses import dataclass, field
|
|
36
36
|
from decimal import Decimal as _Decimal
|
|
37
37
|
from enum import Enum
|
|
@@ -67,6 +67,73 @@ _ENV_REFERENCE_RE = re.compile(r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)")
|
|
|
67
67
|
_SAFE_PATH_SEGMENT_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$")
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
def _skip_powerfx_opaque_token(formula: str, start: int) -> int:
|
|
71
|
+
"""Return the end of an ordinary quoted token or comment, or start if neither."""
|
|
72
|
+
quote = formula[start]
|
|
73
|
+
if quote in ('"', "'"):
|
|
74
|
+
pos = start + 1
|
|
75
|
+
while pos < len(formula):
|
|
76
|
+
if formula[pos] == quote:
|
|
77
|
+
if pos + 1 < len(formula) and formula[pos + 1] == quote:
|
|
78
|
+
pos += 2
|
|
79
|
+
continue
|
|
80
|
+
return pos + 1
|
|
81
|
+
pos += 1
|
|
82
|
+
return pos
|
|
83
|
+
if formula.startswith("//", start):
|
|
84
|
+
pos = start + 2
|
|
85
|
+
while pos < len(formula) and formula[pos] not in "\r\n":
|
|
86
|
+
pos += 1
|
|
87
|
+
return pos
|
|
88
|
+
if formula.startswith("/*", start):
|
|
89
|
+
end = formula.find("*/", start + 2)
|
|
90
|
+
return len(formula) if end == -1 else end + 2
|
|
91
|
+
return start
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _iter_powerfx_expression_indices(formula: str) -> Iterator[int]:
|
|
95
|
+
"""Yield code positions, excluding literals/comments but including interpolation expressions."""
|
|
96
|
+
# None denotes interpolated text; integers track record braces in expression sections.
|
|
97
|
+
scopes: list[int | None] = [0]
|
|
98
|
+
cursor = 0
|
|
99
|
+
while cursor < len(formula):
|
|
100
|
+
depth = scopes[-1]
|
|
101
|
+
char = formula[cursor]
|
|
102
|
+
if depth is None:
|
|
103
|
+
if formula.startswith(('""', "{{", "}}"), cursor):
|
|
104
|
+
cursor += 2
|
|
105
|
+
continue
|
|
106
|
+
if char == "{":
|
|
107
|
+
scopes.append(0)
|
|
108
|
+
elif char == '"':
|
|
109
|
+
scopes.pop()
|
|
110
|
+
cursor += 1
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
if formula.startswith('$"', cursor):
|
|
114
|
+
scopes.append(None)
|
|
115
|
+
cursor += 2
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
token_end = _skip_powerfx_opaque_token(formula, cursor)
|
|
119
|
+
if token_end != cursor:
|
|
120
|
+
cursor = token_end
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
if char == "{":
|
|
124
|
+
scopes[-1] = depth + 1
|
|
125
|
+
elif char == "}":
|
|
126
|
+
if depth > 0:
|
|
127
|
+
scopes[-1] = depth - 1
|
|
128
|
+
elif len(scopes) > 1:
|
|
129
|
+
scopes.pop()
|
|
130
|
+
cursor += 1
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
yield cursor
|
|
134
|
+
cursor += 1
|
|
135
|
+
|
|
136
|
+
|
|
70
137
|
@dataclass(frozen=True)
|
|
71
138
|
class DeclarativeEnvConfig:
|
|
72
139
|
"""Configuration that populates the PowerFx ``Env`` symbol for a workflow.
|
|
@@ -667,11 +734,11 @@ class DeclarativeWorkflowState:
|
|
|
667
734
|
When they appear nested inside other functions (e.g., Upper(MessageText(...))),
|
|
668
735
|
we need to evaluate them first and replace with the result.
|
|
669
736
|
|
|
670
|
-
|
|
671
|
-
to
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
737
|
+
Results are stored in temporary state variables so untrusted message text is
|
|
738
|
+
passed to PowerFx as data rather than inserted into formula source.
|
|
739
|
+
Temporary names avoid existing Local keys and references in the original formula.
|
|
740
|
+
Literal text, quoted identifiers, and comments are left untouched; expression
|
|
741
|
+
sections inside PowerFx interpolated strings are preprocessed as code.
|
|
675
742
|
|
|
676
743
|
Args:
|
|
677
744
|
formula: The PowerFx formula to pre-process
|
|
@@ -684,90 +751,53 @@ class DeclarativeWorkflowState:
|
|
|
684
751
|
Returns:
|
|
685
752
|
The rewritten formula.
|
|
686
753
|
"""
|
|
687
|
-
import re
|
|
688
|
-
|
|
689
|
-
# Threshold for storing in state vs embedding as literal.
|
|
690
|
-
# The Python PowerFx wrapper defaults to a 1000 char expression limit (vs 10,000 in .NET).
|
|
691
|
-
# We use 500 to leave room for the rest of the expression around the replaced value.
|
|
692
|
-
MAX_INLINE_LENGTH = 500
|
|
693
|
-
|
|
694
754
|
temp_var_counter = 0
|
|
695
|
-
|
|
696
|
-
#
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
755
|
+
reserved_names = {name.casefold() for name in self.get_state_data().get("Local", {})}
|
|
756
|
+
# Reserve formula references too, so previously undefined names stay undefined.
|
|
757
|
+
folded_formula = formula.casefold()
|
|
758
|
+
function_name = "MessageText"
|
|
759
|
+
call_prefix = f"{function_name}("
|
|
760
|
+
result: list[str] = []
|
|
761
|
+
copied_until = 0
|
|
762
|
+
positions = _iter_powerfx_expression_indices(formula)
|
|
763
|
+
|
|
764
|
+
for cursor in positions:
|
|
765
|
+
if not formula.startswith(call_prefix, cursor):
|
|
766
|
+
continue
|
|
767
|
+
|
|
768
|
+
paren_start = cursor + len(function_name)
|
|
769
|
+
depth = 1
|
|
770
|
+
for pos in positions:
|
|
771
|
+
if pos <= paren_start:
|
|
772
|
+
continue
|
|
773
|
+
char = formula[pos]
|
|
774
|
+
if char == "(":
|
|
775
|
+
depth += 1
|
|
776
|
+
elif char == ")":
|
|
777
|
+
depth -= 1
|
|
778
|
+
if depth == 0:
|
|
706
779
|
break
|
|
780
|
+
else:
|
|
781
|
+
break
|
|
707
782
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
while pos < len(formula) and depth > 0:
|
|
717
|
-
char = formula[pos]
|
|
718
|
-
if escape_next:
|
|
719
|
-
escape_next = False
|
|
720
|
-
pos += 1
|
|
721
|
-
continue
|
|
722
|
-
if char == "\\":
|
|
723
|
-
escape_next = True
|
|
724
|
-
pos += 1
|
|
725
|
-
continue
|
|
726
|
-
if char == '"' and not escape_next:
|
|
727
|
-
in_string = not in_string
|
|
728
|
-
elif not in_string:
|
|
729
|
-
if char == "(":
|
|
730
|
-
depth += 1
|
|
731
|
-
elif char == ")":
|
|
732
|
-
depth -= 1
|
|
733
|
-
pos += 1
|
|
734
|
-
|
|
735
|
-
if depth != 0:
|
|
736
|
-
# Malformed expression, skip
|
|
783
|
+
inner_expr = formula[paren_start + 1 : pos]
|
|
784
|
+
replacement = self._eval_and_replace_message_text(inner_expr)
|
|
785
|
+
while True:
|
|
786
|
+
temp_var_name = f"_TempMessageText{temp_var_counter}"
|
|
787
|
+
temp_var_counter += 1
|
|
788
|
+
folded_name = temp_var_name.casefold()
|
|
789
|
+
if folded_name not in reserved_names and folded_name not in folded_formula:
|
|
737
790
|
break
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
if len(replacement) > MAX_INLINE_LENGTH:
|
|
749
|
-
# Store long results in an underscore-prefixed temp key;
|
|
750
|
-
# record the prior value so eval() can restore it.
|
|
751
|
-
temp_var_name = f"_TempMessageText{temp_var_counter}"
|
|
752
|
-
temp_var_counter += 1
|
|
753
|
-
temp_var_path = f"Local.{temp_var_name}"
|
|
754
|
-
temp_writes.append((temp_var_path, self.get(temp_var_path, default=self._MISSING)))
|
|
755
|
-
self.set(temp_var_path, replacement)
|
|
756
|
-
replacement_str = temp_var_path
|
|
757
|
-
logger.debug(
|
|
758
|
-
f"Stored long MessageText result ({len(replacement)} chars) "
|
|
759
|
-
f"in temp variable {temp_var_name}"
|
|
760
|
-
)
|
|
761
|
-
else:
|
|
762
|
-
# Short strings can be embedded directly
|
|
763
|
-
escaped = replacement.replace('"', '""')
|
|
764
|
-
replacement_str = f'"{escaped}"'
|
|
765
|
-
else:
|
|
766
|
-
replacement_str = str(replacement) if replacement is not None else '""'
|
|
767
|
-
|
|
768
|
-
formula = formula[:start] + replacement_str + formula[end:]
|
|
769
|
-
|
|
770
|
-
return formula
|
|
791
|
+
temp_var_path = f"Local.{temp_var_name}"
|
|
792
|
+
temp_writes.append((temp_var_path, self.get(temp_var_path, default=self._MISSING)))
|
|
793
|
+
self.set(temp_var_path, replacement)
|
|
794
|
+
result.append(formula[copied_until:cursor])
|
|
795
|
+
result.append(temp_var_path)
|
|
796
|
+
logger.debug(f"Stored MessageText result ({len(replacement)} chars) in temp variable {temp_var_name}")
|
|
797
|
+
copied_until = pos + 1
|
|
798
|
+
|
|
799
|
+
result.append(formula[copied_until:])
|
|
800
|
+
return "".join(result)
|
|
771
801
|
|
|
772
802
|
def _eval_and_replace_message_text(self, inner_expr: str) -> str:
|
|
773
803
|
"""Evaluate MessageText() and return the text result.
|
|
@@ -788,16 +788,28 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
|
|
|
788
788
|
_validate_conversation_history(messages_for_agent, agent_name)
|
|
789
789
|
|
|
790
790
|
# Retrieve kwargs passed to workflow.run() so they propagate to agent tools
|
|
791
|
-
from agent_framework._workflows
|
|
792
|
-
|
|
793
|
-
|
|
791
|
+
from agent_framework._workflows import _agent_utils as workflow_agent_utils
|
|
792
|
+
from agent_framework._workflows import _const as workflow_const
|
|
793
|
+
|
|
794
|
+
run_kwargs: dict[str, Any] = ctx.get_state(workflow_const.WORKFLOW_RUN_KWARGS_KEY, {})
|
|
795
|
+
prepare_run_kwargs = getattr(workflow_agent_utils, "prepare_executor_run_kwargs", None)
|
|
796
|
+
resolved_state_key = getattr(workflow_const, "RESOLVED_WORKFLOW_RUN_KWARGS_KEY", None)
|
|
797
|
+
if callable(prepare_run_kwargs) and isinstance(resolved_state_key, str):
|
|
798
|
+
missing_resolved_state = object()
|
|
799
|
+
resolved_run_kwargs: Any = ctx.get_state(resolved_state_key, missing_resolved_state)
|
|
800
|
+
if resolved_run_kwargs is not missing_resolved_state:
|
|
801
|
+
if not isinstance(resolved_run_kwargs, dict):
|
|
802
|
+
raise TypeError("Resolved workflow run kwargs state must be a dict.")
|
|
803
|
+
run_kwargs = cast(Any, prepare_run_kwargs)(self.id, run_kwargs, resolved_run_kwargs)
|
|
794
804
|
options: dict[str, Any] | None = None
|
|
795
805
|
if run_kwargs:
|
|
796
806
|
# Merge caller-provided options to avoid duplicate keyword argument
|
|
797
807
|
options = dict(run_kwargs.get("options") or {})
|
|
798
808
|
options["additional_function_arguments"] = run_kwargs
|
|
799
|
-
# Exclude 'options' from splat to avoid TypeError on duplicate keyword
|
|
800
|
-
|
|
809
|
+
# Exclude 'options' from splat to avoid TypeError on duplicate keyword,
|
|
810
|
+
# and keep internal workflow-routing copies (stored under underscore
|
|
811
|
+
# keys for nested executors) out of the public Agent.run signature
|
|
812
|
+
run_kwargs = {k: v for k, v in run_kwargs.items() if k != "options" and not k.startswith("_")}
|
|
801
813
|
|
|
802
814
|
# Use run() method to get properly structured messages (including tool calls and results)
|
|
803
815
|
# This is critical for multi-turn conversations where tool calls must be followed
|
|
@@ -6,9 +6,8 @@ Mirrors the .NET ``HttpRequestExecutor``: dispatches an HTTP request through the
|
|
|
6
6
|
configured :class:`HttpRequestHandler`, parses the response body, and assigns
|
|
7
7
|
the parsed body and response headers to the declared state paths.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
collapse CR/LF/TAB to spaces (parity with .NET ``FormatBodyForDiagnostics``).
|
|
9
|
+
Response bodies are excluded from non-2xx exceptions because they may contain
|
|
10
|
+
private backend data. Request URLs and status codes remain available for diagnostics.
|
|
12
11
|
"""
|
|
13
12
|
|
|
14
13
|
from __future__ import annotations
|
|
@@ -40,10 +39,6 @@ __all__ = [
|
|
|
40
39
|
|
|
41
40
|
logger = logging.getLogger(__name__)
|
|
42
41
|
|
|
43
|
-
_MAX_BODY_DIAGNOSTIC_LENGTH = 256
|
|
44
|
-
_BODY_TRUNCATION_SUFFIX = " \u2026 [truncated]"
|
|
45
|
-
|
|
46
|
-
|
|
47
42
|
# Body discriminator aliases. Long forms match the .NET object-model type
|
|
48
43
|
# names so YAML produced by .NET round-trips. Short forms are the .NET YAML
|
|
49
44
|
# convention used in test fixtures.
|
|
@@ -69,24 +64,6 @@ def _get_path(action_def: Mapping[str, Any], key: str) -> str | None:
|
|
|
69
64
|
return None
|
|
70
65
|
|
|
71
66
|
|
|
72
|
-
def _format_body_for_diagnostics(body: str | None) -> str:
|
|
73
|
-
"""Truncate and sanitise a response body for inclusion in error messages.
|
|
74
|
-
|
|
75
|
-
Mirrors the .NET ``FormatBodyForDiagnostics`` helper:
|
|
76
|
-
|
|
77
|
-
- Empty/None -> empty string.
|
|
78
|
-
- Replaces CR/LF/TAB with spaces.
|
|
79
|
-
- Truncates to 256 chars with a unicode-ellipsis ``[truncated]`` suffix.
|
|
80
|
-
"""
|
|
81
|
-
if not body:
|
|
82
|
-
return ""
|
|
83
|
-
|
|
84
|
-
truncated = len(body) > _MAX_BODY_DIAGNOSTIC_LENGTH
|
|
85
|
-
head = body[:_MAX_BODY_DIAGNOSTIC_LENGTH] if truncated else body
|
|
86
|
-
sanitized = head.replace("\r", " ").replace("\n", " ").replace("\t", " ")
|
|
87
|
-
return sanitized + _BODY_TRUNCATION_SUFFIX if truncated else sanitized
|
|
88
|
-
|
|
89
|
-
|
|
90
67
|
def _parse_response_body(body: str | None) -> Any:
|
|
91
68
|
"""Parse an HTTP response body the same way the .NET executor does.
|
|
92
69
|
|
|
@@ -148,8 +125,8 @@ class HttpRequestActionExecutor(DeclarativeActionExecutor):
|
|
|
148
125
|
an Assistant :class:`agent_framework.Message` to
|
|
149
126
|
``System.conversations.{id}.messages``.
|
|
150
127
|
- On non-2xx, still publishes ``responseHeaders`` (diagnostic) and raises
|
|
151
|
-
:class:`DeclarativeActionError` with
|
|
152
|
-
|
|
128
|
+
:class:`DeclarativeActionError` with the request URL and status code,
|
|
129
|
+
without including the response body.
|
|
153
130
|
|
|
154
131
|
Transport errors (``httpx.TimeoutException``, ``TimeoutError``,
|
|
155
132
|
``httpx.HTTPError``) become :class:`DeclarativeActionError`. ``CancelledError``
|
|
@@ -229,12 +206,7 @@ class HttpRequestActionExecutor(DeclarativeActionExecutor):
|
|
|
229
206
|
|
|
230
207
|
# Non-success path: still publish headers diagnostically, then raise.
|
|
231
208
|
self._assign_response_headers(state, result)
|
|
232
|
-
|
|
233
|
-
if body_preview:
|
|
234
|
-
message = f"HTTP request to '{url}' failed with status code {result.status_code}. Body: '{body_preview}'"
|
|
235
|
-
else:
|
|
236
|
-
message = f"HTTP request to '{url}' failed with status code {result.status_code}."
|
|
237
|
-
raise DeclarativeActionError(message)
|
|
209
|
+
raise DeclarativeActionError(f"HTTP request to '{url}' failed with status code {result.status_code}.")
|
|
238
210
|
|
|
239
211
|
# ----- Field resolution ----------------------------------------------------
|
|
240
212
|
|
|
@@ -241,7 +241,7 @@ class WorkflowFactory:
|
|
|
241
241
|
if not yaml_path.exists():
|
|
242
242
|
raise FileNotFoundError(f"Workflow YAML file not found: {yaml_path}")
|
|
243
243
|
|
|
244
|
-
with open(yaml_path) as f:
|
|
244
|
+
with open(yaml_path, encoding="utf-8") as f:
|
|
245
245
|
yaml_content = f.read()
|
|
246
246
|
|
|
247
247
|
return self.create_workflow_from_yaml(yaml_content, base_path=yaml_path.parent)
|
|
@@ -23,6 +23,7 @@ from __future__ import annotations
|
|
|
23
23
|
import asyncio
|
|
24
24
|
from collections.abc import Awaitable, Callable, Mapping
|
|
25
25
|
from dataclasses import dataclass, field
|
|
26
|
+
from http.cookiejar import CookieJar, DefaultCookiePolicy
|
|
26
27
|
from typing import Any, Protocol, runtime_checkable
|
|
27
28
|
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
|
|
28
29
|
|
|
@@ -122,12 +123,18 @@ class DefaultHttpRequestHandler:
|
|
|
122
123
|
Construction modes:
|
|
123
124
|
|
|
124
125
|
1. ``DefaultHttpRequestHandler()`` — owns an internal client created lazily
|
|
125
|
-
on first ``send()
|
|
126
|
+
on first ``send()`` without response-cookie persistence. Closed by :meth:`aclose`.
|
|
126
127
|
2. ``DefaultHttpRequestHandler(client=existing)`` — caller-owned client.
|
|
127
|
-
|
|
128
|
+
Retains its cookie behavior and is not closed by :meth:`aclose`.
|
|
128
129
|
3. ``DefaultHttpRequestHandler(client_provider=cb)`` — per-request client
|
|
129
130
|
lookup (parity with .NET's ``httpClientProvider`` callback). The
|
|
130
|
-
provider
|
|
131
|
+
provider's clients retain their cookie behavior and are not closed by :meth:`aclose`.
|
|
132
|
+
Returning ``None`` falls back to ``client``, if supplied, then to the owned client.
|
|
133
|
+
|
|
134
|
+
Applications requiring persistent cookies must supply a client through ``client``
|
|
135
|
+
or ``client_provider`` scoped to one authenticated principal and manage its lifetime.
|
|
136
|
+
Explicit outbound ``Cookie`` headers and response ``Set-Cookie`` headers are preserved;
|
|
137
|
+
the owned-client policy only prevents automatic cookie persistence.
|
|
131
138
|
|
|
132
139
|
.. warning::
|
|
133
140
|
|
|
@@ -262,7 +269,9 @@ class DefaultHttpRequestHandler:
|
|
|
262
269
|
# one of them.
|
|
263
270
|
async with self._owned_client_lock:
|
|
264
271
|
if self._owned_client is None:
|
|
265
|
-
self._owned_client = httpx.AsyncClient(
|
|
272
|
+
self._owned_client = httpx.AsyncClient(
|
|
273
|
+
cookies=CookieJar(policy=DefaultCookiePolicy(allowed_domains=[])),
|
|
274
|
+
)
|
|
266
275
|
return self._owned_client
|
|
267
276
|
|
|
268
277
|
async def __aenter__(self) -> DefaultHttpRequestHandler:
|
|
@@ -32,6 +32,7 @@ import json
|
|
|
32
32
|
import logging
|
|
33
33
|
from collections import OrderedDict
|
|
34
34
|
from collections.abc import Awaitable, Callable
|
|
35
|
+
from contextvars import ContextVar, Token
|
|
35
36
|
from dataclasses import dataclass, field
|
|
36
37
|
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast, runtime_checkable
|
|
37
38
|
|
|
@@ -157,14 +158,14 @@ class _CacheEntry:
|
|
|
157
158
|
class DefaultMCPToolHandler:
|
|
158
159
|
"""Default :class:`MCPToolHandler` backed by :class:`agent_framework.MCPStreamableHTTPTool`.
|
|
159
160
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
161
|
+
Without a ``client_provider``, caches one
|
|
162
|
+
:class:`agent_framework.MCPStreamableHTTPTool` instance per
|
|
163
|
+
``(server_url, server_label, connection_name, headers_hash)`` in a bounded
|
|
164
|
+
LRU. The cache prevents re-establishing an MCP session for every
|
|
163
165
|
invocation while ensuring different header sets (auth tokens) cannot
|
|
164
166
|
share a session — matches the .NET design intent while bounding
|
|
165
|
-
cardinality. ``server_label`` and ``connection_name`` participate
|
|
166
|
-
the key
|
|
167
|
-
fields receive a fresh client per logical connection (see below).
|
|
167
|
+
cardinality. ``server_label`` and ``connection_name`` also participate
|
|
168
|
+
in the key to distinguish logical connections.
|
|
168
169
|
Header *names* are lower-cased inside the hash payload only — the
|
|
169
170
|
headers passed on the wire keep the caller's original casing — so two
|
|
170
171
|
YAML actions that spell ``Authorization`` differently still share a
|
|
@@ -174,12 +175,20 @@ class DefaultMCPToolHandler:
|
|
|
174
175
|
|
|
175
176
|
1. ``DefaultMCPToolHandler()`` — owns its own ``httpx.AsyncClient``
|
|
176
177
|
instances created lazily per cache entry. Closed by :meth:`aclose`.
|
|
177
|
-
2. ``DefaultMCPToolHandler(client_provider=cb)`` — per-
|
|
178
|
+
2. ``DefaultMCPToolHandler(client_provider=cb)`` — per-invocation client
|
|
178
179
|
lookup (parity with .NET ``httpClientProvider`` callback). The
|
|
179
180
|
callback receives the full :class:`MCPToolInvocation` so it can
|
|
180
181
|
dispatch on ``server_url`` / ``connection_name`` / ``server_label``.
|
|
181
|
-
|
|
182
|
-
|
|
182
|
+
The callback is invoked for every call, including ``tools/list``.
|
|
183
|
+
Each call creates and closes its own MCP tool/session, even when the
|
|
184
|
+
callback returns ``None`` or the same client object. Returning ``None``
|
|
185
|
+
falls back to an internally-created client, closed with the invocation.
|
|
186
|
+
Caller-supplied clients are never closed by this handler.
|
|
187
|
+
|
|
188
|
+
This intentionally trades connection/session reuse for invocation
|
|
189
|
+
isolation. Server session state is not retained across invocations;
|
|
190
|
+
callers needing shared session ownership must implement an explicitly
|
|
191
|
+
scoped custom :class:`MCPToolHandler`.
|
|
183
192
|
|
|
184
193
|
.. warning::
|
|
185
194
|
|
|
@@ -187,11 +196,11 @@ class DefaultMCPToolHandler:
|
|
|
187
196
|
or replace it with a custom handler in production deployments.
|
|
188
197
|
|
|
189
198
|
Args:
|
|
190
|
-
client_provider: Optional per-
|
|
191
|
-
cache_max_size: Maximum number of cached MCP clients
|
|
192
|
-
the least-recently-used entry is evicted and its
|
|
193
|
-
|
|
194
|
-
|
|
199
|
+
client_provider: Optional per-invocation ``httpx.AsyncClient`` provider.
|
|
200
|
+
cache_max_size: Maximum number of cached MCP clients in no-provider mode.
|
|
201
|
+
When exceeded, the least-recently-used entry is evicted and its
|
|
202
|
+
owned client closed. Defaults to ``32``. Does not enable session
|
|
203
|
+
caching when a provider is configured.
|
|
195
204
|
"""
|
|
196
205
|
|
|
197
206
|
LIST_TOOLS_TOOL_NAME: ClassVar[str] = "tools/list"
|
|
@@ -227,12 +236,19 @@ class DefaultMCPToolHandler:
|
|
|
227
236
|
# tasks awaiting the same key will await the same future and share
|
|
228
237
|
# the resulting cache entry.
|
|
229
238
|
self._inflight: dict[tuple[str, str, str, str], asyncio.Future[_CacheEntry]] = {}
|
|
239
|
+
# Completion signals only: provider-backed calls never share entries.
|
|
240
|
+
self._active_invocations: set[asyncio.Future[None]] = set()
|
|
241
|
+
# Keep ancestry so a completed nested call cannot hide an active parent
|
|
242
|
+
# in the context inherited by child tasks, including cleanup tasks.
|
|
243
|
+
self._invocation_context: ContextVar[tuple[asyncio.Future[None], ...]] = ContextVar(
|
|
244
|
+
f"default_mcp_tool_handler_invocations_{id(self)}", default=()
|
|
245
|
+
)
|
|
230
246
|
# Set by ``aclose`` to prevent post-close cache insertions and to
|
|
231
247
|
# reject new ``invoke_tool`` calls. Once set, never cleared.
|
|
232
248
|
self._closed = False
|
|
233
249
|
|
|
234
250
|
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
|
|
235
|
-
"""Invoke ``invocation.tool_name`` on
|
|
251
|
+
"""Invoke ``invocation.tool_name`` on an MCP client for the server.
|
|
236
252
|
|
|
237
253
|
The reserved name :attr:`LIST_TOOLS_TOOL_NAME` (``"tools/list"``) is
|
|
238
254
|
intercepted client-side: instead of being forwarded as a tool call,
|
|
@@ -241,7 +257,6 @@ class DefaultMCPToolHandler:
|
|
|
241
257
|
``TextContent`` containing a JSON tool catalog.
|
|
242
258
|
"""
|
|
243
259
|
from agent_framework import Content
|
|
244
|
-
from agent_framework.exceptions import ToolExecutionException
|
|
245
260
|
|
|
246
261
|
# Reserved-name args validation runs before connect: rejecting bad
|
|
247
262
|
# input shouldn't require establishing an MCP session.
|
|
@@ -253,23 +268,55 @@ class DefaultMCPToolHandler:
|
|
|
253
268
|
error_message=message,
|
|
254
269
|
)
|
|
255
270
|
|
|
271
|
+
entry: _CacheEntry | None = None
|
|
272
|
+
completion: asyncio.Future[None] | None = None
|
|
273
|
+
context_token: Token[tuple[asyncio.Future[None], ...]] | None = None
|
|
256
274
|
try:
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
275
|
+
try:
|
|
276
|
+
if self._client_provider is None:
|
|
277
|
+
entry = await self._get_or_create_entry(invocation)
|
|
278
|
+
else:
|
|
279
|
+
async with self._cache_lock:
|
|
280
|
+
if self._closed:
|
|
281
|
+
raise RuntimeError("DefaultMCPToolHandler is closed")
|
|
282
|
+
completion = asyncio.get_running_loop().create_future()
|
|
283
|
+
self._active_invocations.add(completion)
|
|
284
|
+
context_token = self._invocation_context.set((*self._invocation_context.get(), completion))
|
|
285
|
+
entry = await self._create_entry(invocation)
|
|
286
|
+
if self._closed:
|
|
287
|
+
raise RuntimeError("DefaultMCPToolHandler is closed")
|
|
288
|
+
except Exception as exc:
|
|
289
|
+
# Connect / cache lookup failures surface as tool errors so the
|
|
290
|
+
# workflow can store them at output.result without crashing.
|
|
291
|
+
logger.warning(
|
|
292
|
+
"DefaultMCPToolHandler: failed to obtain MCP client for url=%s tool=%s: %s",
|
|
293
|
+
invocation.server_url,
|
|
294
|
+
invocation.tool_name,
|
|
295
|
+
exc,
|
|
296
|
+
)
|
|
297
|
+
message = f"Failed to connect to MCP server: {type(exc).__name__}: {exc}".rstrip(": ")
|
|
298
|
+
return MCPToolResult(
|
|
299
|
+
outputs=[Content.from_text(f"Error: {message}")],
|
|
300
|
+
is_error=True,
|
|
301
|
+
error_message=message,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
return await self._invoke_entry(entry, invocation)
|
|
305
|
+
finally:
|
|
306
|
+
if completion is not None:
|
|
307
|
+
try:
|
|
308
|
+
if entry is not None:
|
|
309
|
+
await self._close_invocation_entry(entry)
|
|
310
|
+
finally:
|
|
311
|
+
if context_token is not None:
|
|
312
|
+
self._invocation_context.reset(context_token)
|
|
313
|
+
self._active_invocations.discard(completion)
|
|
314
|
+
completion.set_result(None)
|
|
315
|
+
|
|
316
|
+
async def _invoke_entry(self, entry: _CacheEntry, invocation: MCPToolInvocation) -> MCPToolResult:
|
|
317
|
+
"""Dispatch a connected invocation and preserve tool error mapping."""
|
|
318
|
+
from agent_framework import Content
|
|
319
|
+
from agent_framework.exceptions import ToolExecutionException
|
|
273
320
|
|
|
274
321
|
try:
|
|
275
322
|
if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME:
|
|
@@ -372,24 +419,42 @@ class DefaultMCPToolHandler:
|
|
|
372
419
|
return MCPToolResult(outputs=[Content.from_text(json.dumps(payload, indent=2, allow_nan=False))])
|
|
373
420
|
|
|
374
421
|
async def aclose(self) -> None:
|
|
375
|
-
"""Close
|
|
422
|
+
"""Close cached clients and wait for provider-backed invocations to clean up.
|
|
376
423
|
|
|
377
424
|
Caller-supplied :class:`httpx.AsyncClient` instances (returned by the
|
|
378
425
|
``client_provider`` callback) are NOT closed.
|
|
379
426
|
|
|
380
|
-
|
|
381
|
-
|
|
427
|
+
Provider-backed calls already executing may finish; pending connections
|
|
428
|
+
are rejected after connecting and cleaned up by their invocation.
|
|
429
|
+
Concurrent shutdown calls wait for those invocations as well.
|
|
430
|
+
|
|
431
|
+
Idempotent. In no-provider mode, a second call returns immediately.
|
|
432
|
+
Drains any in-flight ``_create_entry`` tasks before returning so their resources are
|
|
382
433
|
cleaned up; the in-flight tasks see ``self._closed`` in phase 3 of
|
|
383
434
|
:meth:`_get_or_create_entry`, close their own entry, and resolve
|
|
384
435
|
their future with ``RuntimeError("DefaultMCPToolHandler is closed")``.
|
|
436
|
+
|
|
437
|
+
Raises:
|
|
438
|
+
RuntimeError: If called from an active provider-backed invocation's
|
|
439
|
+
context, including inherited child tasks and cleanup. Rejected
|
|
440
|
+
before changing handler state to avoid waiting on itself.
|
|
385
441
|
"""
|
|
442
|
+
if any(not completion.done() for completion in self._invocation_context.get()):
|
|
443
|
+
raise RuntimeError(
|
|
444
|
+
"DefaultMCPToolHandler.aclose() cannot be called from an active provider-backed invocation"
|
|
445
|
+
)
|
|
386
446
|
async with self._cache_lock:
|
|
387
|
-
if self._closed:
|
|
447
|
+
if self._closed and self._client_provider is None:
|
|
388
448
|
return
|
|
389
449
|
self._closed = True
|
|
390
450
|
entries = list(self._cache.values())
|
|
391
451
|
self._cache.clear()
|
|
392
452
|
inflight_futures = list(self._inflight.values())
|
|
453
|
+
active_invocations = list(self._active_invocations)
|
|
454
|
+
|
|
455
|
+
for completion in active_invocations:
|
|
456
|
+
# Cancelling shutdown must not cancel an invocation's completion signal.
|
|
457
|
+
await asyncio.shield(completion)
|
|
393
458
|
|
|
394
459
|
# Wait for in-flight creations to finish their self-cleanup. Each
|
|
395
460
|
# in-flight task self-closes its entry under the closed-flag branch
|
|
@@ -509,7 +574,9 @@ class DefaultMCPToolHandler:
|
|
|
509
574
|
provided_client: httpx.AsyncClient | None = None
|
|
510
575
|
if self._client_provider is not None:
|
|
511
576
|
provided_client = await self._client_provider(invocation)
|
|
512
|
-
|
|
577
|
+
if self._closed:
|
|
578
|
+
raise RuntimeError("DefaultMCPToolHandler is closed")
|
|
579
|
+
# Capture headers for this entry so the header_provider closure
|
|
513
580
|
# always returns the same set, regardless of the runtime kwargs.
|
|
514
581
|
captured_headers = dict(invocation.headers)
|
|
515
582
|
|
|
@@ -526,10 +593,18 @@ class DefaultMCPToolHandler:
|
|
|
526
593
|
try:
|
|
527
594
|
await tool.connect()
|
|
528
595
|
except BaseException:
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
596
|
+
failed_entry = _CacheEntry(
|
|
597
|
+
tool=tool,
|
|
598
|
+
owned_httpx_client=(
|
|
599
|
+
cast("httpx.AsyncClient | None", getattr(tool, "_httpx_client", None))
|
|
600
|
+
if provided_client is None
|
|
601
|
+
else None
|
|
602
|
+
),
|
|
603
|
+
)
|
|
604
|
+
if self._client_provider is not None:
|
|
605
|
+
await self._close_invocation_entry(failed_entry)
|
|
606
|
+
else:
|
|
607
|
+
await self._close_entry(failed_entry)
|
|
533
608
|
raise
|
|
534
609
|
|
|
535
610
|
# ``MCPStreamableHTTPTool.get_mcp_client`` lazily creates an
|
|
@@ -542,17 +617,34 @@ class DefaultMCPToolHandler:
|
|
|
542
617
|
owned_client = cast("httpx.AsyncClient | None", getattr(tool, "_httpx_client", None))
|
|
543
618
|
return _CacheEntry(tool=tool, owned_httpx_client=owned_client)
|
|
544
619
|
|
|
620
|
+
async def _close_invocation_entry(self, entry: _CacheEntry) -> None:
|
|
621
|
+
"""Finish invocation cleanup even if the caller is cancelled again."""
|
|
622
|
+
# MCPStreamableHTTPTool dispatches connect/close to its lifecycle owner,
|
|
623
|
+
# keeping the SDK's cancel-scope entry and exit on that same task.
|
|
624
|
+
cleanup = asyncio.create_task(self._close_entry(entry))
|
|
625
|
+
cancelled = False
|
|
626
|
+
while not cleanup.done():
|
|
627
|
+
try:
|
|
628
|
+
await asyncio.shield(cleanup)
|
|
629
|
+
except asyncio.CancelledError:
|
|
630
|
+
cancelled = True
|
|
631
|
+
cleanup.result() # Propagate cancellation/errors from cleanup itself.
|
|
632
|
+
if cancelled:
|
|
633
|
+
raise asyncio.CancelledError
|
|
634
|
+
|
|
545
635
|
async def _close_entry(self, entry: _CacheEntry) -> None:
|
|
546
636
|
"""Close the MCP tool and any owned httpx client."""
|
|
547
637
|
try:
|
|
548
|
-
await entry.tool.close()
|
|
549
|
-
except Exception: # pragma: no cover - best effort
|
|
550
|
-
logger.debug("DefaultMCPToolHandler: error closing MCP tool", exc_info=True)
|
|
551
|
-
if entry.owned_httpx_client is not None:
|
|
552
638
|
try:
|
|
553
|
-
await entry.
|
|
639
|
+
await entry.tool.close()
|
|
554
640
|
except Exception: # pragma: no cover - best effort
|
|
555
|
-
logger.debug("DefaultMCPToolHandler: error closing
|
|
641
|
+
logger.debug("DefaultMCPToolHandler: error closing MCP tool", exc_info=True)
|
|
642
|
+
finally:
|
|
643
|
+
if entry.owned_httpx_client is not None and not entry.owned_httpx_client.is_closed:
|
|
644
|
+
try:
|
|
645
|
+
await entry.owned_httpx_client.aclose()
|
|
646
|
+
except Exception: # pragma: no cover - best effort
|
|
647
|
+
logger.debug("DefaultMCPToolHandler: error closing owned httpx client", exc_info=True)
|
|
556
648
|
|
|
557
649
|
@staticmethod
|
|
558
650
|
def _cache_key(
|
|
@@ -563,10 +655,9 @@ class DefaultMCPToolHandler:
|
|
|
563
655
|
) -> tuple[str, str, str, str]:
|
|
564
656
|
"""Build an order-independent cache key for the invocation identity.
|
|
565
657
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
documented dispatch contract).
|
|
658
|
+
Used only without a ``client_provider``. The key includes
|
|
659
|
+
``server_label`` and ``connection_name`` to distinguish logical
|
|
660
|
+
connections.
|
|
570
661
|
|
|
571
662
|
Header *names* are lower-cased inside the hash payload only so
|
|
572
663
|
that ``Authorization`` and ``authorization`` map to the same
|
|
@@ -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.0
|
|
7
|
+
version = "1.1.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,14 +22,14 @@ classifiers = [
|
|
|
22
22
|
"Typing :: Typed",
|
|
23
23
|
]
|
|
24
24
|
dependencies = [
|
|
25
|
-
"agent-framework-core>=1.
|
|
25
|
+
"agent-framework-core>=1.19.0,<2",
|
|
26
26
|
"httpx>=0.27,<1",
|
|
27
27
|
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
|
|
28
28
|
"pyyaml>=6.0,<7.0",
|
|
29
29
|
]
|
|
30
30
|
[dependency-groups]
|
|
31
31
|
dev = [
|
|
32
|
-
"types-PyYaml==6.0.12.
|
|
32
|
+
"types-PyYaml==6.0.12.20260906"
|
|
33
33
|
]
|
|
34
34
|
|
|
35
35
|
[tool.uv]
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: agent-framework-declarative
|
|
3
|
-
Version: 1.0.4
|
|
4
|
-
Summary: Declarative specification support for Microsoft Agent Framework.
|
|
5
|
-
Author-email: Microsoft <af-support@microsoft.com>
|
|
6
|
-
Requires-Python: >=3.10
|
|
7
|
-
Description-Content-Type: text/markdown
|
|
8
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
-
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
-
Classifier: Intended Audience :: Developers
|
|
11
|
-
Classifier: Programming Language :: Python :: 3
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
-
Classifier: Typing :: Typed
|
|
17
|
-
License-File: LICENSE
|
|
18
|
-
Requires-Dist: agent-framework-core>=1.15.0,<2
|
|
19
|
-
Requires-Dist: httpx>=0.27,<1
|
|
20
|
-
Requires-Dist: powerfx>=0.0.32,<0.0.35; python_version < '3.14'
|
|
21
|
-
Requires-Dist: pyyaml>=6.0,<7.0
|
|
22
|
-
Project-URL: homepage, https://aka.ms/agent-framework
|
|
23
|
-
Project-URL: issues, https://github.com/microsoft/agent-framework/issues
|
|
24
|
-
Project-URL: release_notes, https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true
|
|
25
|
-
Project-URL: source, https://github.com/microsoft/agent-framework/tree/main/python
|
|
26
|
-
|
|
27
|
-
# Get Started with Microsoft Agent Framework Declarative
|
|
28
|
-
|
|
29
|
-
Please install this package via pip:
|
|
30
|
-
|
|
31
|
-
```bash
|
|
32
|
-
pip install agent-framework-declarative
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
## Release stage
|
|
36
|
-
|
|
37
|
-
This package ships at two different stability levels:
|
|
38
|
-
|
|
39
|
-
- **Declarative workflows** (`WorkflowFactory`, executors, handlers, and the
|
|
40
|
-
`_workflows` surface) are **stable**.
|
|
41
|
-
- **Declarative agents** (`AgentFactory` and the YAML agent loading/parsing path:
|
|
42
|
-
`DeclarativeLoaderError`, `ProviderLookupError`, `ProviderTypeMapping`) are
|
|
43
|
-
**experimental** and may change or be removed in future versions without notice.
|
|
44
|
-
Using any of these symbols emits an `ExperimentalWarning` on first use.
|
|
45
|
-
|
|
46
|
-
## Declarative features
|
|
47
|
-
|
|
48
|
-
The declarative packages provides support for building agents based on a declarative yaml specification.
|
|
49
|
-
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
# Get Started with Microsoft Agent Framework Declarative
|
|
2
|
-
|
|
3
|
-
Please install this package via pip:
|
|
4
|
-
|
|
5
|
-
```bash
|
|
6
|
-
pip install agent-framework-declarative
|
|
7
|
-
```
|
|
8
|
-
|
|
9
|
-
## Release stage
|
|
10
|
-
|
|
11
|
-
This package ships at two different stability levels:
|
|
12
|
-
|
|
13
|
-
- **Declarative workflows** (`WorkflowFactory`, executors, handlers, and the
|
|
14
|
-
`_workflows` surface) are **stable**.
|
|
15
|
-
- **Declarative agents** (`AgentFactory` and the YAML agent loading/parsing path:
|
|
16
|
-
`DeclarativeLoaderError`, `ProviderLookupError`, `ProviderTypeMapping`) are
|
|
17
|
-
**experimental** and may change or be removed in future versions without notice.
|
|
18
|
-
Using any of these symbols emits an `ExperimentalWarning` on first use.
|
|
19
|
-
|
|
20
|
-
## Declarative features
|
|
21
|
-
|
|
22
|
-
The declarative packages provides support for building agents based on a declarative yaml specification.
|
|
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
|