agno 2.3.21__py3-none-any.whl → 2.3.23__py3-none-any.whl
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.
- agno/agent/agent.py +48 -2
- agno/agent/remote.py +234 -73
- agno/client/a2a/__init__.py +10 -0
- agno/client/a2a/client.py +554 -0
- agno/client/a2a/schemas.py +112 -0
- agno/client/a2a/utils.py +369 -0
- agno/db/migrations/utils.py +19 -0
- agno/db/migrations/v1_to_v2.py +54 -16
- agno/db/migrations/versions/v2_3_0.py +92 -53
- agno/db/mysql/async_mysql.py +5 -7
- agno/db/mysql/mysql.py +5 -7
- agno/db/mysql/schemas.py +39 -21
- agno/db/postgres/async_postgres.py +172 -42
- agno/db/postgres/postgres.py +186 -38
- agno/db/postgres/schemas.py +39 -21
- agno/db/postgres/utils.py +6 -2
- agno/db/singlestore/schemas.py +41 -21
- agno/db/singlestore/singlestore.py +14 -3
- agno/db/sqlite/async_sqlite.py +7 -2
- agno/db/sqlite/schemas.py +36 -21
- agno/db/sqlite/sqlite.py +3 -7
- agno/knowledge/chunking/document.py +3 -2
- agno/knowledge/chunking/markdown.py +8 -3
- agno/knowledge/chunking/recursive.py +2 -2
- agno/models/base.py +4 -0
- agno/models/google/gemini.py +27 -4
- agno/models/openai/chat.py +1 -1
- agno/models/openai/responses.py +14 -7
- agno/os/middleware/jwt.py +66 -27
- agno/os/routers/agents/router.py +3 -3
- agno/os/routers/evals/evals.py +2 -2
- agno/os/routers/knowledge/knowledge.py +5 -5
- agno/os/routers/knowledge/schemas.py +1 -1
- agno/os/routers/memory/memory.py +4 -4
- agno/os/routers/session/session.py +2 -2
- agno/os/routers/teams/router.py +4 -4
- agno/os/routers/traces/traces.py +3 -3
- agno/os/routers/workflows/router.py +3 -3
- agno/os/schema.py +1 -1
- agno/reasoning/deepseek.py +11 -1
- agno/reasoning/gemini.py +6 -2
- agno/reasoning/groq.py +8 -3
- agno/reasoning/openai.py +2 -0
- agno/remote/base.py +106 -9
- agno/skills/__init__.py +17 -0
- agno/skills/agent_skills.py +370 -0
- agno/skills/errors.py +32 -0
- agno/skills/loaders/__init__.py +4 -0
- agno/skills/loaders/base.py +27 -0
- agno/skills/loaders/local.py +216 -0
- agno/skills/skill.py +65 -0
- agno/skills/utils.py +107 -0
- agno/skills/validator.py +277 -0
- agno/team/remote.py +220 -60
- agno/team/team.py +41 -3
- agno/tools/brandfetch.py +27 -18
- agno/tools/browserbase.py +150 -13
- agno/tools/function.py +6 -1
- agno/tools/mcp/mcp.py +300 -17
- agno/tools/mcp/multi_mcp.py +269 -14
- agno/tools/toolkit.py +89 -21
- agno/utils/mcp.py +49 -8
- agno/utils/string.py +43 -1
- agno/workflow/condition.py +4 -2
- agno/workflow/loop.py +20 -1
- agno/workflow/remote.py +173 -33
- agno/workflow/router.py +4 -1
- agno/workflow/steps.py +4 -0
- agno/workflow/workflow.py +14 -0
- {agno-2.3.21.dist-info → agno-2.3.23.dist-info}/METADATA +13 -14
- {agno-2.3.21.dist-info → agno-2.3.23.dist-info}/RECORD +74 -60
- {agno-2.3.21.dist-info → agno-2.3.23.dist-info}/WHEEL +0 -0
- {agno-2.3.21.dist-info → agno-2.3.23.dist-info}/licenses/LICENSE +0 -0
- {agno-2.3.21.dist-info → agno-2.3.23.dist-info}/top_level.txt +0 -0
agno/tools/browserbase.py
CHANGED
|
@@ -10,13 +10,6 @@ try:
|
|
|
10
10
|
except ImportError:
|
|
11
11
|
raise ImportError("`browserbase` not installed. Please install using `pip install browserbase`")
|
|
12
12
|
|
|
13
|
-
try:
|
|
14
|
-
from playwright.sync_api import sync_playwright
|
|
15
|
-
except ImportError:
|
|
16
|
-
raise ImportError(
|
|
17
|
-
"`playwright` not installed. Please install using `pip install playwright` and run `playwright install`"
|
|
18
|
-
)
|
|
19
|
-
|
|
20
13
|
|
|
21
14
|
class BrowserbaseTools(Toolkit):
|
|
22
15
|
def __init__(
|
|
@@ -36,7 +29,13 @@ class BrowserbaseTools(Toolkit):
|
|
|
36
29
|
Args:
|
|
37
30
|
api_key (str, optional): Browserbase API key.
|
|
38
31
|
project_id (str, optional): Browserbase project ID.
|
|
39
|
-
base_url (str, optional): Custom Browserbase API endpoint URL (NOT the target website URL).
|
|
32
|
+
base_url (str, optional): Custom Browserbase API endpoint URL (NOT the target website URL).
|
|
33
|
+
Only use this if you're using a self-hosted Browserbase instance or need to connect to a different region.
|
|
34
|
+
enable_navigate_to (bool): Enable the navigate_to tool. Defaults to True.
|
|
35
|
+
enable_screenshot (bool): Enable the screenshot tool. Defaults to True.
|
|
36
|
+
enable_get_page_content (bool): Enable the get_page_content tool. Defaults to True.
|
|
37
|
+
enable_close_session (bool): Enable the close_session tool. Defaults to True.
|
|
38
|
+
all (bool): Enable all tools. Defaults to False.
|
|
40
39
|
"""
|
|
41
40
|
self.api_key = api_key or getenv("BROWSERBASE_API_KEY")
|
|
42
41
|
if not self.api_key:
|
|
@@ -59,23 +58,40 @@ class BrowserbaseTools(Toolkit):
|
|
|
59
58
|
else:
|
|
60
59
|
self.app = Browserbase(api_key=self.api_key)
|
|
61
60
|
|
|
61
|
+
# Sync playwright state
|
|
62
62
|
self._playwright = None
|
|
63
63
|
self._browser = None
|
|
64
64
|
self._page = None
|
|
65
|
+
|
|
66
|
+
# Async playwright state
|
|
67
|
+
self._async_playwright = None
|
|
68
|
+
self._async_browser = None
|
|
69
|
+
self._async_page = None
|
|
70
|
+
|
|
71
|
+
# Shared session state
|
|
65
72
|
self._session = None
|
|
66
73
|
self._connect_url = None
|
|
67
74
|
|
|
75
|
+
# Build tools lists
|
|
76
|
+
# sync tools: used by agent.run() and agent.print_response()
|
|
77
|
+
# async tools: used by agent.arun() and agent.aprint_response()
|
|
68
78
|
tools: List[Any] = []
|
|
79
|
+
async_tools: List[tuple] = []
|
|
80
|
+
|
|
69
81
|
if all or enable_navigate_to:
|
|
70
82
|
tools.append(self.navigate_to)
|
|
83
|
+
async_tools.append((self.anavigate_to, "navigate_to"))
|
|
71
84
|
if all or enable_screenshot:
|
|
72
85
|
tools.append(self.screenshot)
|
|
86
|
+
async_tools.append((self.ascreenshot, "screenshot"))
|
|
73
87
|
if all or enable_get_page_content:
|
|
74
88
|
tools.append(self.get_page_content)
|
|
89
|
+
async_tools.append((self.aget_page_content, "get_page_content"))
|
|
75
90
|
if all or enable_close_session:
|
|
76
91
|
tools.append(self.close_session)
|
|
92
|
+
async_tools.append((self.aclose_session, "close_session"))
|
|
77
93
|
|
|
78
|
-
super().__init__(name="browserbase_tools", tools=tools, **kwargs)
|
|
94
|
+
super().__init__(name="browserbase_tools", tools=tools, async_tools=async_tools, **kwargs)
|
|
79
95
|
|
|
80
96
|
def _ensure_session(self):
|
|
81
97
|
"""Ensures a session exists, creating one if needed."""
|
|
@@ -91,9 +107,16 @@ class BrowserbaseTools(Toolkit):
|
|
|
91
107
|
|
|
92
108
|
def _initialize_browser(self, connect_url: Optional[str] = None):
|
|
93
109
|
"""
|
|
94
|
-
Initialize browser connection if not already initialized.
|
|
110
|
+
Initialize sync browser connection if not already initialized.
|
|
95
111
|
Use provided connect_url or ensure we have a session with a connect_url
|
|
96
112
|
"""
|
|
113
|
+
try:
|
|
114
|
+
from playwright.sync_api import sync_playwright # type: ignore[import-not-found]
|
|
115
|
+
except ImportError:
|
|
116
|
+
raise ImportError(
|
|
117
|
+
"`playwright` not installed. Please install using `pip install playwright` and run `playwright install`"
|
|
118
|
+
)
|
|
119
|
+
|
|
97
120
|
if connect_url:
|
|
98
121
|
self._connect_url = connect_url if connect_url else "" # type: ignore
|
|
99
122
|
elif not self._connect_url:
|
|
@@ -107,7 +130,7 @@ class BrowserbaseTools(Toolkit):
|
|
|
107
130
|
self._page = context.pages[0] or context.new_page() # type: ignore
|
|
108
131
|
|
|
109
132
|
def _cleanup(self):
|
|
110
|
-
"""Clean up browser resources."""
|
|
133
|
+
"""Clean up sync browser resources."""
|
|
111
134
|
if self._browser:
|
|
112
135
|
self._browser.close()
|
|
113
136
|
self._browser = None
|
|
@@ -186,8 +209,7 @@ class BrowserbaseTools(Toolkit):
|
|
|
186
209
|
|
|
187
210
|
def close_session(self) -> str:
|
|
188
211
|
"""Closes a browser session.
|
|
189
|
-
|
|
190
|
-
session_id (str, optional): The session ID to close. If not provided, will use the current session.
|
|
212
|
+
|
|
191
213
|
Returns:
|
|
192
214
|
JSON string with closure status
|
|
193
215
|
"""
|
|
@@ -207,3 +229,118 @@ class BrowserbaseTools(Toolkit):
|
|
|
207
229
|
)
|
|
208
230
|
except Exception as e:
|
|
209
231
|
return json.dumps({"status": "warning", "message": f"Cleanup completed with warning: {str(e)}"})
|
|
232
|
+
|
|
233
|
+
async def _ainitialize_browser(self, connect_url: Optional[str] = None):
|
|
234
|
+
"""
|
|
235
|
+
Initialize async browser connection if not already initialized.
|
|
236
|
+
Use provided connect_url or ensure we have a session with a connect_url
|
|
237
|
+
"""
|
|
238
|
+
try:
|
|
239
|
+
from playwright.async_api import async_playwright # type: ignore[import-not-found]
|
|
240
|
+
except ImportError:
|
|
241
|
+
raise ImportError(
|
|
242
|
+
"`playwright` not installed. Please install using `pip install playwright` and run `playwright install`"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
if connect_url:
|
|
246
|
+
self._connect_url = connect_url if connect_url else "" # type: ignore
|
|
247
|
+
elif not self._connect_url:
|
|
248
|
+
self._ensure_session()
|
|
249
|
+
|
|
250
|
+
if not self._async_playwright:
|
|
251
|
+
self._async_playwright = await async_playwright().start() # type: ignore
|
|
252
|
+
if self._async_playwright:
|
|
253
|
+
self._async_browser = await self._async_playwright.chromium.connect_over_cdp(self._connect_url)
|
|
254
|
+
context = self._async_browser.contexts[0] if self._async_browser else None
|
|
255
|
+
if context:
|
|
256
|
+
self._async_page = context.pages[0] if context.pages else await context.new_page()
|
|
257
|
+
|
|
258
|
+
async def _acleanup(self):
|
|
259
|
+
"""Clean up async browser resources."""
|
|
260
|
+
if self._async_browser:
|
|
261
|
+
await self._async_browser.close()
|
|
262
|
+
self._async_browser = None
|
|
263
|
+
if self._async_playwright:
|
|
264
|
+
await self._async_playwright.stop()
|
|
265
|
+
self._async_playwright = None
|
|
266
|
+
self._async_page = None
|
|
267
|
+
|
|
268
|
+
async def anavigate_to(self, url: str, connect_url: Optional[str] = None) -> str:
|
|
269
|
+
"""Navigates to a URL asynchronously.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
url (str): The URL to navigate to
|
|
273
|
+
connect_url (str, optional): The connection URL from an existing session
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
JSON string with navigation status
|
|
277
|
+
"""
|
|
278
|
+
try:
|
|
279
|
+
await self._ainitialize_browser(connect_url)
|
|
280
|
+
if self._async_page:
|
|
281
|
+
await self._async_page.goto(url, wait_until="networkidle")
|
|
282
|
+
title = await self._async_page.title() if self._async_page else ""
|
|
283
|
+
result = {"status": "complete", "title": title, "url": url}
|
|
284
|
+
return json.dumps(result)
|
|
285
|
+
except Exception as e:
|
|
286
|
+
await self._acleanup()
|
|
287
|
+
raise e
|
|
288
|
+
|
|
289
|
+
async def ascreenshot(self, path: str, full_page: bool = True, connect_url: Optional[str] = None) -> str:
|
|
290
|
+
"""Takes a screenshot of the current page asynchronously.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
path (str): Where to save the screenshot
|
|
294
|
+
full_page (bool): Whether to capture the full page
|
|
295
|
+
connect_url (str, optional): The connection URL from an existing session
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
JSON string confirming screenshot was saved
|
|
299
|
+
"""
|
|
300
|
+
try:
|
|
301
|
+
await self._ainitialize_browser(connect_url)
|
|
302
|
+
if self._async_page:
|
|
303
|
+
await self._async_page.screenshot(path=path, full_page=full_page)
|
|
304
|
+
return json.dumps({"status": "success", "path": path})
|
|
305
|
+
except Exception as e:
|
|
306
|
+
await self._acleanup()
|
|
307
|
+
raise e
|
|
308
|
+
|
|
309
|
+
async def aget_page_content(self, connect_url: Optional[str] = None) -> str:
|
|
310
|
+
"""Gets the HTML content of the current page asynchronously.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
connect_url (str, optional): The connection URL from an existing session
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
The page HTML content
|
|
317
|
+
"""
|
|
318
|
+
try:
|
|
319
|
+
await self._ainitialize_browser(connect_url)
|
|
320
|
+
return await self._async_page.content() if self._async_page else ""
|
|
321
|
+
except Exception as e:
|
|
322
|
+
await self._acleanup()
|
|
323
|
+
raise e
|
|
324
|
+
|
|
325
|
+
async def aclose_session(self) -> str:
|
|
326
|
+
"""Closes a browser session asynchronously.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
JSON string with closure status
|
|
330
|
+
"""
|
|
331
|
+
try:
|
|
332
|
+
# First cleanup our local browser resources
|
|
333
|
+
await self._acleanup()
|
|
334
|
+
|
|
335
|
+
# Reset session state
|
|
336
|
+
self._session = None
|
|
337
|
+
self._connect_url = None
|
|
338
|
+
|
|
339
|
+
return json.dumps(
|
|
340
|
+
{
|
|
341
|
+
"status": "closed",
|
|
342
|
+
"message": "Browser resources cleaned up. Session will auto-close if not already closed.",
|
|
343
|
+
}
|
|
344
|
+
)
|
|
345
|
+
except Exception as e:
|
|
346
|
+
return json.dumps({"status": "warning", "message": f"Cleanup completed with warning: {str(e)}"})
|
agno/tools/function.py
CHANGED
|
@@ -1139,10 +1139,15 @@ class FunctionCall(BaseModel):
|
|
|
1139
1139
|
else:
|
|
1140
1140
|
result = self.function.entrypoint(**entrypoint_args, **self.arguments)
|
|
1141
1141
|
|
|
1142
|
+
# Handle both sync and async entrypoints
|
|
1142
1143
|
if isasyncgenfunction(self.function.entrypoint):
|
|
1143
1144
|
self.result = result # Store async generator directly
|
|
1145
|
+
elif iscoroutinefunction(self.function.entrypoint):
|
|
1146
|
+
self.result = await result # Await coroutine result
|
|
1147
|
+
elif isgeneratorfunction(self.function.entrypoint):
|
|
1148
|
+
self.result = result # Store sync generator directly
|
|
1144
1149
|
else:
|
|
1145
|
-
self.result =
|
|
1150
|
+
self.result = result # Sync function, result is already computed
|
|
1146
1151
|
|
|
1147
1152
|
# Only cache if not a generator
|
|
1148
1153
|
if self.function.cache_results and not (isgenerator(self.result) or isasyncgen(self.result)):
|
agno/tools/mcp/mcp.py
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import time
|
|
1
3
|
import weakref
|
|
2
4
|
from dataclasses import asdict
|
|
3
5
|
from datetime import timedelta
|
|
4
|
-
from typing import Any, Literal, Optional, Union
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Tuple, Union
|
|
5
7
|
|
|
6
8
|
from agno.tools import Toolkit
|
|
7
9
|
from agno.tools.function import Function
|
|
8
10
|
from agno.tools.mcp.params import SSEClientParams, StreamableHTTPClientParams
|
|
9
|
-
from agno.utils.log import log_debug, log_error, log_info
|
|
11
|
+
from agno.utils.log import log_debug, log_error, log_info, log_warning
|
|
10
12
|
from agno.utils.mcp import get_entrypoint_for_tool, prepare_command
|
|
11
13
|
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from agno.agent import Agent
|
|
16
|
+
from agno.run import RunContext
|
|
17
|
+
from agno.team.team import Team
|
|
18
|
+
|
|
12
19
|
try:
|
|
13
20
|
from mcp import ClientSession, StdioServerParameters
|
|
14
21
|
from mcp.client.sse import sse_client
|
|
@@ -35,7 +42,7 @@ class MCPTools(Toolkit):
|
|
|
35
42
|
*,
|
|
36
43
|
url: Optional[str] = None,
|
|
37
44
|
env: Optional[dict[str, str]] = None,
|
|
38
|
-
transport: Literal["stdio", "sse", "streamable-http"] =
|
|
45
|
+
transport: Optional[Literal["stdio", "sse", "streamable-http"]] = None,
|
|
39
46
|
server_params: Optional[Union[StdioServerParameters, SSEClientParams, StreamableHTTPClientParams]] = None,
|
|
40
47
|
session: Optional[ClientSession] = None,
|
|
41
48
|
timeout_seconds: int = 10,
|
|
@@ -44,6 +51,7 @@ class MCPTools(Toolkit):
|
|
|
44
51
|
exclude_tools: Optional[list[str]] = None,
|
|
45
52
|
refresh_connection: bool = False,
|
|
46
53
|
tool_name_prefix: Optional[str] = None,
|
|
54
|
+
header_provider: Optional[Callable[..., dict[str, Any]]] = None,
|
|
47
55
|
**kwargs,
|
|
48
56
|
):
|
|
49
57
|
"""
|
|
@@ -59,11 +67,24 @@ class MCPTools(Toolkit):
|
|
|
59
67
|
timeout_seconds: Read timeout in seconds for the MCP client
|
|
60
68
|
include_tools: Optional list of tool names to include (if None, includes all)
|
|
61
69
|
exclude_tools: Optional list of tool names to exclude (if None, excludes none)
|
|
62
|
-
transport: The transport protocol to use, either "stdio" or "sse" or "streamable-http"
|
|
70
|
+
transport: The transport protocol to use, either "stdio" or "sse" or "streamable-http".
|
|
71
|
+
Defaults to "streamable-http" when url is provided, otherwise defaults to "stdio".
|
|
63
72
|
refresh_connection: If True, the connection and tools will be refreshed on each run
|
|
73
|
+
header_provider: Optional function to generate dynamic HTTP headers.
|
|
74
|
+
Only relevant with HTTP transports (Streamable HTTP or SSE).
|
|
75
|
+
Creates a new session per agent run with dynamic headers merged into connection config.
|
|
64
76
|
"""
|
|
65
77
|
super().__init__(name="MCPTools", **kwargs)
|
|
66
78
|
|
|
79
|
+
if url is not None:
|
|
80
|
+
if transport is None:
|
|
81
|
+
transport = "streamable-http"
|
|
82
|
+
elif transport == "stdio":
|
|
83
|
+
log_warning(
|
|
84
|
+
"Transport cannot be 'stdio' when url is provided. Setting transport to 'streamable-http' instead."
|
|
85
|
+
)
|
|
86
|
+
transport = "streamable-http"
|
|
87
|
+
|
|
67
88
|
if transport == "sse":
|
|
68
89
|
log_info("SSE as a standalone transport is deprecated. Please use Streamable HTTP instead.")
|
|
69
90
|
|
|
@@ -104,12 +125,17 @@ class MCPTools(Toolkit):
|
|
|
104
125
|
"If using the streamable-http transport, server_params must be an instance of StreamableHTTPClientParams."
|
|
105
126
|
)
|
|
106
127
|
|
|
128
|
+
self.transport = transport
|
|
129
|
+
|
|
130
|
+
self.header_provider = None
|
|
131
|
+
if self._is_valid_header_provider(header_provider):
|
|
132
|
+
self.header_provider = header_provider
|
|
133
|
+
|
|
107
134
|
self.timeout_seconds = timeout_seconds
|
|
108
135
|
self.session: Optional[ClientSession] = session
|
|
109
136
|
self.server_params: Optional[Union[StdioServerParameters, SSEClientParams, StreamableHTTPClientParams]] = (
|
|
110
137
|
server_params
|
|
111
138
|
)
|
|
112
|
-
self.transport = transport
|
|
113
139
|
self.url = url
|
|
114
140
|
|
|
115
141
|
# Merge provided env with system env
|
|
@@ -135,6 +161,12 @@ class MCPTools(Toolkit):
|
|
|
135
161
|
self._context = None
|
|
136
162
|
self._session_context = None
|
|
137
163
|
|
|
164
|
+
# Session management for per-agent-run sessions with dynamic headers
|
|
165
|
+
# Maps run_id to (session, timestamp) for TTL-based cleanup
|
|
166
|
+
self._run_sessions: dict[str, Tuple[ClientSession, float]] = {}
|
|
167
|
+
self._run_session_contexts: dict[str, Any] = {} # Maps run_id to session context managers
|
|
168
|
+
self._session_ttl_seconds: float = 300.0 # 5 minutes TTL for MCP sessions
|
|
169
|
+
|
|
138
170
|
def cleanup():
|
|
139
171
|
"""Cancel active connections"""
|
|
140
172
|
if self._connection_task and not self._connection_task.done():
|
|
@@ -147,6 +179,234 @@ class MCPTools(Toolkit):
|
|
|
147
179
|
def initialized(self) -> bool:
|
|
148
180
|
return self._initialized
|
|
149
181
|
|
|
182
|
+
def _is_valid_header_provider(self, header_provider: Optional[Callable[..., dict[str, Any]]]) -> bool:
|
|
183
|
+
"""Logic to validate a given header_provider function.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
header_provider: The header_provider function to validate
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
Exception: If there is an error validating the header_provider.
|
|
190
|
+
"""
|
|
191
|
+
if not header_provider:
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
if self.transport not in ["sse", "streamable-http"]:
|
|
195
|
+
log_warning(
|
|
196
|
+
f"header_provider specified but transport is '{self.transport}'. "
|
|
197
|
+
"Dynamic headers only work with 'sse' or 'streamable-http' transports. "
|
|
198
|
+
"The header_provider logic will be ignored."
|
|
199
|
+
)
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
log_debug("Dynamic header support enabled for MCP tools")
|
|
203
|
+
return True
|
|
204
|
+
|
|
205
|
+
def _call_header_provider(
|
|
206
|
+
self,
|
|
207
|
+
run_context: Optional["RunContext"] = None,
|
|
208
|
+
agent: Optional["Agent"] = None,
|
|
209
|
+
team: Optional["Team"] = None,
|
|
210
|
+
) -> dict[str, Any]:
|
|
211
|
+
"""Call the header_provider with run_context, agent, and/or team based on its signature.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
run_context: The RunContext for the current agent run
|
|
215
|
+
agent: The Agent instance (if running within an agent)
|
|
216
|
+
team: The Team instance (if running within a team)
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
dict[str, Any]: The headers returned by the header_provider
|
|
220
|
+
"""
|
|
221
|
+
header_provider = getattr(self, "header_provider", None)
|
|
222
|
+
if header_provider is None:
|
|
223
|
+
return {}
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
sig = inspect.signature(header_provider)
|
|
227
|
+
param_names = set(sig.parameters.keys())
|
|
228
|
+
|
|
229
|
+
# Build kwargs based on what the function accepts
|
|
230
|
+
call_kwargs: dict[str, Any] = {}
|
|
231
|
+
|
|
232
|
+
if "run_context" in param_names:
|
|
233
|
+
call_kwargs["run_context"] = run_context
|
|
234
|
+
if "agent" in param_names:
|
|
235
|
+
call_kwargs["agent"] = agent
|
|
236
|
+
if "team" in param_names:
|
|
237
|
+
call_kwargs["team"] = team
|
|
238
|
+
|
|
239
|
+
# Check if function accepts **kwargs (VAR_KEYWORD)
|
|
240
|
+
has_var_keyword = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
|
|
241
|
+
|
|
242
|
+
if has_var_keyword:
|
|
243
|
+
# Pass all available context to **kwargs
|
|
244
|
+
call_kwargs = {"run_context": run_context, "agent": agent, "team": team}
|
|
245
|
+
return header_provider(**call_kwargs)
|
|
246
|
+
elif call_kwargs:
|
|
247
|
+
return header_provider(**call_kwargs)
|
|
248
|
+
else:
|
|
249
|
+
# Function takes no recognized parameters - check for positional
|
|
250
|
+
positional_params = [
|
|
251
|
+
p
|
|
252
|
+
for p in sig.parameters.values()
|
|
253
|
+
if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
254
|
+
]
|
|
255
|
+
if positional_params:
|
|
256
|
+
# Legacy support: pass run_context as first positional arg
|
|
257
|
+
return header_provider(run_context)
|
|
258
|
+
else:
|
|
259
|
+
# Function takes no parameters
|
|
260
|
+
return header_provider()
|
|
261
|
+
except Exception as e:
|
|
262
|
+
log_warning(f"Error calling header_provider: {e}")
|
|
263
|
+
return {}
|
|
264
|
+
|
|
265
|
+
async def _cleanup_stale_sessions(self) -> None:
|
|
266
|
+
"""Clean up sessions older than TTL to prevent memory leaks."""
|
|
267
|
+
if not self._run_sessions:
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
now = time.time()
|
|
271
|
+
stale_run_ids = [
|
|
272
|
+
run_id
|
|
273
|
+
for run_id, (_, created_at) in self._run_sessions.items()
|
|
274
|
+
if now - created_at > self._session_ttl_seconds
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
for run_id in stale_run_ids:
|
|
278
|
+
log_debug(f"Cleaning up stale MCP sessions for run_id={run_id}")
|
|
279
|
+
await self.cleanup_run_session(run_id)
|
|
280
|
+
|
|
281
|
+
async def get_session_for_run(
|
|
282
|
+
self,
|
|
283
|
+
run_context: Optional["RunContext"] = None,
|
|
284
|
+
agent: Optional["Agent"] = None,
|
|
285
|
+
team: Optional["Team"] = None,
|
|
286
|
+
) -> ClientSession:
|
|
287
|
+
"""
|
|
288
|
+
Get or create a session for the given run context.
|
|
289
|
+
|
|
290
|
+
If header_provider is set and run_context is provided, creates a new session
|
|
291
|
+
with dynamic headers merged into the connection config.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
run_context: The RunContext for the current agent run
|
|
295
|
+
agent: The Agent instance (if running within an agent)
|
|
296
|
+
team: The Team instance (if running within a team)
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
ClientSession for the run
|
|
300
|
+
"""
|
|
301
|
+
# If no header_provider or no run_context, use the default session
|
|
302
|
+
if not self.header_provider or not run_context:
|
|
303
|
+
if self.session is None:
|
|
304
|
+
raise ValueError("Session is not initialized")
|
|
305
|
+
return self.session
|
|
306
|
+
|
|
307
|
+
# Lazy cleanup of stale sessions
|
|
308
|
+
await self._cleanup_stale_sessions()
|
|
309
|
+
|
|
310
|
+
# Check if we already have a session for this run
|
|
311
|
+
run_id = run_context.run_id
|
|
312
|
+
if run_id in self._run_sessions:
|
|
313
|
+
session, _ = self._run_sessions[run_id]
|
|
314
|
+
return session
|
|
315
|
+
|
|
316
|
+
# Create a new session with dynamic headers for this run
|
|
317
|
+
log_debug(f"Creating new session for run_id={run_id} with dynamic headers")
|
|
318
|
+
|
|
319
|
+
# Generate dynamic headers from the provider
|
|
320
|
+
dynamic_headers = self._call_header_provider(run_context=run_context, agent=agent, team=team)
|
|
321
|
+
|
|
322
|
+
# Create new session with merged headers based on transport type
|
|
323
|
+
if self.transport == "sse":
|
|
324
|
+
sse_params = asdict(self.server_params) if self.server_params is not None else {} # type: ignore
|
|
325
|
+
if "url" not in sse_params:
|
|
326
|
+
sse_params["url"] = self.url
|
|
327
|
+
|
|
328
|
+
# Merge dynamic headers into existing headers
|
|
329
|
+
existing_headers = sse_params.get("headers", {})
|
|
330
|
+
sse_params["headers"] = {**existing_headers, **dynamic_headers}
|
|
331
|
+
|
|
332
|
+
context = sse_client(**sse_params) # type: ignore
|
|
333
|
+
client_timeout = min(self.timeout_seconds, sse_params.get("timeout", self.timeout_seconds))
|
|
334
|
+
|
|
335
|
+
elif self.transport == "streamable-http":
|
|
336
|
+
streamable_http_params = asdict(self.server_params) if self.server_params is not None else {} # type: ignore
|
|
337
|
+
if "url" not in streamable_http_params:
|
|
338
|
+
streamable_http_params["url"] = self.url
|
|
339
|
+
|
|
340
|
+
# Merge dynamic headers into existing headers
|
|
341
|
+
existing_headers = streamable_http_params.get("headers", {})
|
|
342
|
+
streamable_http_params["headers"] = {**existing_headers, **dynamic_headers}
|
|
343
|
+
|
|
344
|
+
context = streamablehttp_client(**streamable_http_params) # type: ignore
|
|
345
|
+
params_timeout = streamable_http_params.get("timeout", self.timeout_seconds)
|
|
346
|
+
if isinstance(params_timeout, timedelta):
|
|
347
|
+
params_timeout = int(params_timeout.total_seconds())
|
|
348
|
+
client_timeout = min(self.timeout_seconds, params_timeout)
|
|
349
|
+
else:
|
|
350
|
+
# stdio doesn't support headers, fall back to default session
|
|
351
|
+
log_warning(f"Cannot use dynamic headers with {self.transport} transport, using default session")
|
|
352
|
+
if self.session is None:
|
|
353
|
+
raise ValueError("Session is not initialized")
|
|
354
|
+
return self.session
|
|
355
|
+
|
|
356
|
+
# Enter the context and create session
|
|
357
|
+
session_params = await context.__aenter__() # type: ignore
|
|
358
|
+
read, write = session_params[0:2]
|
|
359
|
+
|
|
360
|
+
session_context = ClientSession(read, write, read_timeout_seconds=timedelta(seconds=client_timeout)) # type: ignore
|
|
361
|
+
session = await session_context.__aenter__() # type: ignore
|
|
362
|
+
|
|
363
|
+
# Initialize the session
|
|
364
|
+
await session.initialize()
|
|
365
|
+
|
|
366
|
+
# Store the session with timestamp and context for cleanup
|
|
367
|
+
self._run_sessions[run_id] = (session, time.time())
|
|
368
|
+
self._run_session_contexts[run_id] = (context, session_context)
|
|
369
|
+
|
|
370
|
+
return session
|
|
371
|
+
|
|
372
|
+
async def cleanup_run_session(self, run_id: str) -> None:
|
|
373
|
+
"""
|
|
374
|
+
Clean up the session for a specific run.
|
|
375
|
+
|
|
376
|
+
Note: Cleanup may fail due to async context manager limitations when
|
|
377
|
+
contexts are entered/exited across different tasks. Errors are logged
|
|
378
|
+
but not raised.
|
|
379
|
+
"""
|
|
380
|
+
if run_id not in self._run_sessions:
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
# Get the context managers
|
|
385
|
+
context, session_context = self._run_session_contexts.get(run_id, (None, None))
|
|
386
|
+
|
|
387
|
+
# Try to clean up session context
|
|
388
|
+
# Silently ignore cleanup errors - these are harmless
|
|
389
|
+
if session_context is not None:
|
|
390
|
+
try:
|
|
391
|
+
await session_context.__aexit__(None, None, None)
|
|
392
|
+
except (RuntimeError, Exception):
|
|
393
|
+
pass # Silently ignore
|
|
394
|
+
|
|
395
|
+
# Try to clean up transport context
|
|
396
|
+
if context is not None:
|
|
397
|
+
try:
|
|
398
|
+
await context.__aexit__(None, None, None)
|
|
399
|
+
except (RuntimeError, Exception):
|
|
400
|
+
pass # Silently ignore
|
|
401
|
+
|
|
402
|
+
# Remove from tracking regardless of cleanup success
|
|
403
|
+
# The connections will be cleaned up by garbage collection
|
|
404
|
+
del self._run_sessions[run_id]
|
|
405
|
+
del self._run_session_contexts[run_id]
|
|
406
|
+
|
|
407
|
+
except Exception:
|
|
408
|
+
pass # Silently ignore all cleanup errors
|
|
409
|
+
|
|
150
410
|
async def is_alive(self) -> bool:
|
|
151
411
|
if self.session is None:
|
|
152
412
|
return False
|
|
@@ -227,17 +487,36 @@ class MCPTools(Toolkit):
|
|
|
227
487
|
if not self._initialized:
|
|
228
488
|
return
|
|
229
489
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
490
|
+
import warnings
|
|
491
|
+
|
|
492
|
+
# Suppress async generator cleanup warnings
|
|
493
|
+
with warnings.catch_warnings():
|
|
494
|
+
warnings.filterwarnings("ignore", category=RuntimeWarning, message=".*async_generator.*")
|
|
495
|
+
warnings.filterwarnings("ignore", message=".*cancel scope.*")
|
|
496
|
+
|
|
497
|
+
try:
|
|
498
|
+
# Clean up all per-run sessions first
|
|
499
|
+
run_ids = list(self._run_sessions.keys())
|
|
500
|
+
for run_id in run_ids:
|
|
501
|
+
await self.cleanup_run_session(run_id)
|
|
502
|
+
|
|
503
|
+
# Clean up the main session
|
|
504
|
+
if self._session_context is not None:
|
|
505
|
+
try:
|
|
506
|
+
await self._session_context.__aexit__(None, None, None)
|
|
507
|
+
except (RuntimeError, Exception):
|
|
508
|
+
pass # Silently ignore cleanup errors
|
|
509
|
+
self.session = None
|
|
510
|
+
self._session_context = None
|
|
511
|
+
|
|
512
|
+
if self._context is not None:
|
|
513
|
+
try:
|
|
514
|
+
await self._context.__aexit__(None, None, None)
|
|
515
|
+
except (RuntimeError, Exception):
|
|
516
|
+
pass # Silently ignore cleanup errors
|
|
517
|
+
self._context = None
|
|
518
|
+
except (RuntimeError, BaseException):
|
|
519
|
+
pass # Silently ignore all cleanup errors
|
|
241
520
|
|
|
242
521
|
self._initialized = False
|
|
243
522
|
|
|
@@ -290,7 +569,11 @@ class MCPTools(Toolkit):
|
|
|
290
569
|
for tool in filtered_tools:
|
|
291
570
|
try:
|
|
292
571
|
# Get an entrypoint for the tool
|
|
293
|
-
entrypoint = get_entrypoint_for_tool(
|
|
572
|
+
entrypoint = get_entrypoint_for_tool(
|
|
573
|
+
tool=tool,
|
|
574
|
+
session=self.session, # type: ignore
|
|
575
|
+
mcp_tools_instance=self,
|
|
576
|
+
)
|
|
294
577
|
# Create a Function for the tool
|
|
295
578
|
f = Function(
|
|
296
579
|
name=tool_name_prefix + tool.name,
|