agno 2.3.20__py3-none-any.whl → 2.3.22__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.
Files changed (58) hide show
  1. agno/agent/agent.py +26 -1
  2. agno/agent/remote.py +233 -72
  3. agno/client/a2a/__init__.py +10 -0
  4. agno/client/a2a/client.py +554 -0
  5. agno/client/a2a/schemas.py +112 -0
  6. agno/client/a2a/utils.py +369 -0
  7. agno/db/migrations/utils.py +19 -0
  8. agno/db/migrations/v1_to_v2.py +54 -16
  9. agno/db/migrations/versions/v2_3_0.py +92 -53
  10. agno/db/postgres/async_postgres.py +162 -40
  11. agno/db/postgres/postgres.py +181 -31
  12. agno/db/postgres/utils.py +6 -2
  13. agno/eval/agent_as_judge.py +24 -14
  14. agno/knowledge/chunking/document.py +3 -2
  15. agno/knowledge/chunking/markdown.py +8 -3
  16. agno/knowledge/chunking/recursive.py +2 -2
  17. agno/knowledge/embedder/mistral.py +1 -1
  18. agno/models/openai/chat.py +1 -1
  19. agno/models/openai/responses.py +14 -7
  20. agno/os/middleware/jwt.py +66 -27
  21. agno/os/routers/agents/router.py +2 -2
  22. agno/os/routers/evals/evals.py +0 -9
  23. agno/os/routers/evals/utils.py +6 -6
  24. agno/os/routers/knowledge/knowledge.py +3 -3
  25. agno/os/routers/teams/router.py +2 -2
  26. agno/os/routers/workflows/router.py +2 -2
  27. agno/reasoning/deepseek.py +11 -1
  28. agno/reasoning/gemini.py +6 -2
  29. agno/reasoning/groq.py +8 -3
  30. agno/reasoning/openai.py +2 -0
  31. agno/remote/base.py +105 -8
  32. agno/run/agent.py +19 -19
  33. agno/run/team.py +19 -19
  34. agno/skills/__init__.py +17 -0
  35. agno/skills/agent_skills.py +370 -0
  36. agno/skills/errors.py +32 -0
  37. agno/skills/loaders/__init__.py +4 -0
  38. agno/skills/loaders/base.py +27 -0
  39. agno/skills/loaders/local.py +216 -0
  40. agno/skills/skill.py +65 -0
  41. agno/skills/utils.py +107 -0
  42. agno/skills/validator.py +277 -0
  43. agno/team/remote.py +219 -59
  44. agno/team/team.py +22 -2
  45. agno/tools/mcp/mcp.py +299 -17
  46. agno/tools/mcp/multi_mcp.py +269 -14
  47. agno/utils/mcp.py +49 -8
  48. agno/utils/string.py +43 -1
  49. agno/workflow/condition.py +4 -2
  50. agno/workflow/loop.py +20 -1
  51. agno/workflow/remote.py +172 -32
  52. agno/workflow/router.py +4 -1
  53. agno/workflow/steps.py +4 -0
  54. {agno-2.3.20.dist-info → agno-2.3.22.dist-info}/METADATA +59 -130
  55. {agno-2.3.20.dist-info → agno-2.3.22.dist-info}/RECORD +58 -44
  56. {agno-2.3.20.dist-info → agno-2.3.22.dist-info}/WHEEL +0 -0
  57. {agno-2.3.20.dist-info → agno-2.3.22.dist-info}/licenses/LICENSE +0 -0
  58. {agno-2.3.20.dist-info → agno-2.3.22.dist-info}/top_level.txt +0 -0
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"] = "stdio",
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,16 @@ 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
+ if self._is_valid_header_provider(header_provider):
131
+ self.header_provider = header_provider
132
+
107
133
  self.timeout_seconds = timeout_seconds
108
134
  self.session: Optional[ClientSession] = session
109
135
  self.server_params: Optional[Union[StdioServerParameters, SSEClientParams, StreamableHTTPClientParams]] = (
110
136
  server_params
111
137
  )
112
- self.transport = transport
113
138
  self.url = url
114
139
 
115
140
  # Merge provided env with system env
@@ -135,6 +160,12 @@ class MCPTools(Toolkit):
135
160
  self._context = None
136
161
  self._session_context = None
137
162
 
163
+ # Session management for per-agent-run sessions with dynamic headers
164
+ # Maps run_id to (session, timestamp) for TTL-based cleanup
165
+ self._run_sessions: dict[str, Tuple[ClientSession, float]] = {}
166
+ self._run_session_contexts: dict[str, Any] = {} # Maps run_id to session context managers
167
+ self._session_ttl_seconds: float = 300.0 # 5 minutes TTL for MCP sessions
168
+
138
169
  def cleanup():
139
170
  """Cancel active connections"""
140
171
  if self._connection_task and not self._connection_task.done():
@@ -147,6 +178,234 @@ class MCPTools(Toolkit):
147
178
  def initialized(self) -> bool:
148
179
  return self._initialized
149
180
 
181
+ def _is_valid_header_provider(self, header_provider: Optional[Callable[..., dict[str, Any]]]) -> bool:
182
+ """Logic to validate a given header_provider function.
183
+
184
+ Args:
185
+ header_provider: The header_provider function to validate
186
+
187
+ Raises:
188
+ Exception: If there is an error validating the header_provider.
189
+ """
190
+ if not header_provider:
191
+ return False
192
+
193
+ if self.transport not in ["sse", "streamable-http"]:
194
+ log_warning(
195
+ f"header_provider specified but transport is '{self.transport}'. "
196
+ "Dynamic headers only work with 'sse' or 'streamable-http' transports. "
197
+ "The header_provider logic will be ignored."
198
+ )
199
+ return False
200
+
201
+ log_debug("Dynamic header support enabled for MCP tools")
202
+ return True
203
+
204
+ def _call_header_provider(
205
+ self,
206
+ run_context: Optional["RunContext"] = None,
207
+ agent: Optional["Agent"] = None,
208
+ team: Optional["Team"] = None,
209
+ ) -> dict[str, Any]:
210
+ """Call the header_provider with run_context, agent, and/or team based on its signature.
211
+
212
+ Args:
213
+ run_context: The RunContext for the current agent run
214
+ agent: The Agent instance (if running within an agent)
215
+ team: The Team instance (if running within a team)
216
+
217
+ Returns:
218
+ dict[str, Any]: The headers returned by the header_provider
219
+ """
220
+ header_provider = getattr(self, "header_provider", None)
221
+ if header_provider is None:
222
+ return {}
223
+
224
+ try:
225
+ sig = inspect.signature(header_provider)
226
+ param_names = set(sig.parameters.keys())
227
+
228
+ # Build kwargs based on what the function accepts
229
+ call_kwargs: dict[str, Any] = {}
230
+
231
+ if "run_context" in param_names:
232
+ call_kwargs["run_context"] = run_context
233
+ if "agent" in param_names:
234
+ call_kwargs["agent"] = agent
235
+ if "team" in param_names:
236
+ call_kwargs["team"] = team
237
+
238
+ # Check if function accepts **kwargs (VAR_KEYWORD)
239
+ has_var_keyword = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
240
+
241
+ if has_var_keyword:
242
+ # Pass all available context to **kwargs
243
+ call_kwargs = {"run_context": run_context, "agent": agent, "team": team}
244
+ return header_provider(**call_kwargs)
245
+ elif call_kwargs:
246
+ return header_provider(**call_kwargs)
247
+ else:
248
+ # Function takes no recognized parameters - check for positional
249
+ positional_params = [
250
+ p
251
+ for p in sig.parameters.values()
252
+ if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
253
+ ]
254
+ if positional_params:
255
+ # Legacy support: pass run_context as first positional arg
256
+ return header_provider(run_context)
257
+ else:
258
+ # Function takes no parameters
259
+ return header_provider()
260
+ except Exception as e:
261
+ log_warning(f"Error calling header_provider: {e}")
262
+ return {}
263
+
264
+ async def _cleanup_stale_sessions(self) -> None:
265
+ """Clean up sessions older than TTL to prevent memory leaks."""
266
+ if not self._run_sessions:
267
+ return
268
+
269
+ now = time.time()
270
+ stale_run_ids = [
271
+ run_id
272
+ for run_id, (_, created_at) in self._run_sessions.items()
273
+ if now - created_at > self._session_ttl_seconds
274
+ ]
275
+
276
+ for run_id in stale_run_ids:
277
+ log_debug(f"Cleaning up stale MCP sessions for run_id={run_id}")
278
+ await self.cleanup_run_session(run_id)
279
+
280
+ async def get_session_for_run(
281
+ self,
282
+ run_context: Optional["RunContext"] = None,
283
+ agent: Optional["Agent"] = None,
284
+ team: Optional["Team"] = None,
285
+ ) -> ClientSession:
286
+ """
287
+ Get or create a session for the given run context.
288
+
289
+ If header_provider is set and run_context is provided, creates a new session
290
+ with dynamic headers merged into the connection config.
291
+
292
+ Args:
293
+ run_context: The RunContext for the current agent run
294
+ agent: The Agent instance (if running within an agent)
295
+ team: The Team instance (if running within a team)
296
+
297
+ Returns:
298
+ ClientSession for the run
299
+ """
300
+ # If no header_provider or no run_context, use the default session
301
+ if not self.header_provider or not run_context:
302
+ if self.session is None:
303
+ raise ValueError("Session is not initialized")
304
+ return self.session
305
+
306
+ # Lazy cleanup of stale sessions
307
+ await self._cleanup_stale_sessions()
308
+
309
+ # Check if we already have a session for this run
310
+ run_id = run_context.run_id
311
+ if run_id in self._run_sessions:
312
+ session, _ = self._run_sessions[run_id]
313
+ return session
314
+
315
+ # Create a new session with dynamic headers for this run
316
+ log_debug(f"Creating new session for run_id={run_id} with dynamic headers")
317
+
318
+ # Generate dynamic headers from the provider
319
+ dynamic_headers = self._call_header_provider(run_context=run_context, agent=agent, team=team)
320
+
321
+ # Create new session with merged headers based on transport type
322
+ if self.transport == "sse":
323
+ sse_params = asdict(self.server_params) if self.server_params is not None else {} # type: ignore
324
+ if "url" not in sse_params:
325
+ sse_params["url"] = self.url
326
+
327
+ # Merge dynamic headers into existing headers
328
+ existing_headers = sse_params.get("headers", {})
329
+ sse_params["headers"] = {**existing_headers, **dynamic_headers}
330
+
331
+ context = sse_client(**sse_params) # type: ignore
332
+ client_timeout = min(self.timeout_seconds, sse_params.get("timeout", self.timeout_seconds))
333
+
334
+ elif self.transport == "streamable-http":
335
+ streamable_http_params = asdict(self.server_params) if self.server_params is not None else {} # type: ignore
336
+ if "url" not in streamable_http_params:
337
+ streamable_http_params["url"] = self.url
338
+
339
+ # Merge dynamic headers into existing headers
340
+ existing_headers = streamable_http_params.get("headers", {})
341
+ streamable_http_params["headers"] = {**existing_headers, **dynamic_headers}
342
+
343
+ context = streamablehttp_client(**streamable_http_params) # type: ignore
344
+ params_timeout = streamable_http_params.get("timeout", self.timeout_seconds)
345
+ if isinstance(params_timeout, timedelta):
346
+ params_timeout = int(params_timeout.total_seconds())
347
+ client_timeout = min(self.timeout_seconds, params_timeout)
348
+ else:
349
+ # stdio doesn't support headers, fall back to default session
350
+ log_warning(f"Cannot use dynamic headers with {self.transport} transport, using default session")
351
+ if self.session is None:
352
+ raise ValueError("Session is not initialized")
353
+ return self.session
354
+
355
+ # Enter the context and create session
356
+ session_params = await context.__aenter__() # type: ignore
357
+ read, write = session_params[0:2]
358
+
359
+ session_context = ClientSession(read, write, read_timeout_seconds=timedelta(seconds=client_timeout)) # type: ignore
360
+ session = await session_context.__aenter__() # type: ignore
361
+
362
+ # Initialize the session
363
+ await session.initialize()
364
+
365
+ # Store the session with timestamp and context for cleanup
366
+ self._run_sessions[run_id] = (session, time.time())
367
+ self._run_session_contexts[run_id] = (context, session_context)
368
+
369
+ return session
370
+
371
+ async def cleanup_run_session(self, run_id: str) -> None:
372
+ """
373
+ Clean up the session for a specific run.
374
+
375
+ Note: Cleanup may fail due to async context manager limitations when
376
+ contexts are entered/exited across different tasks. Errors are logged
377
+ but not raised.
378
+ """
379
+ if run_id not in self._run_sessions:
380
+ return
381
+
382
+ try:
383
+ # Get the context managers
384
+ context, session_context = self._run_session_contexts.get(run_id, (None, None))
385
+
386
+ # Try to clean up session context
387
+ # Silently ignore cleanup errors - these are harmless
388
+ if session_context is not None:
389
+ try:
390
+ await session_context.__aexit__(None, None, None)
391
+ except (RuntimeError, Exception):
392
+ pass # Silently ignore
393
+
394
+ # Try to clean up transport context
395
+ if context is not None:
396
+ try:
397
+ await context.__aexit__(None, None, None)
398
+ except (RuntimeError, Exception):
399
+ pass # Silently ignore
400
+
401
+ # Remove from tracking regardless of cleanup success
402
+ # The connections will be cleaned up by garbage collection
403
+ del self._run_sessions[run_id]
404
+ del self._run_session_contexts[run_id]
405
+
406
+ except Exception:
407
+ pass # Silently ignore all cleanup errors
408
+
150
409
  async def is_alive(self) -> bool:
151
410
  if self.session is None:
152
411
  return False
@@ -227,17 +486,36 @@ class MCPTools(Toolkit):
227
486
  if not self._initialized:
228
487
  return
229
488
 
230
- try:
231
- if self._session_context is not None:
232
- await self._session_context.__aexit__(None, None, None)
233
- self.session = None
234
- self._session_context = None
235
-
236
- if self._context is not None:
237
- await self._context.__aexit__(None, None, None)
238
- self._context = None
239
- except (RuntimeError, BaseException) as e:
240
- log_error(f"Failed to close MCP connection: {e}")
489
+ import warnings
490
+
491
+ # Suppress async generator cleanup warnings
492
+ with warnings.catch_warnings():
493
+ warnings.filterwarnings("ignore", category=RuntimeWarning, message=".*async_generator.*")
494
+ warnings.filterwarnings("ignore", message=".*cancel scope.*")
495
+
496
+ try:
497
+ # Clean up all per-run sessions first
498
+ run_ids = list(self._run_sessions.keys())
499
+ for run_id in run_ids:
500
+ await self.cleanup_run_session(run_id)
501
+
502
+ # Clean up the main session
503
+ if self._session_context is not None:
504
+ try:
505
+ await self._session_context.__aexit__(None, None, None)
506
+ except (RuntimeError, Exception):
507
+ pass # Silently ignore cleanup errors
508
+ self.session = None
509
+ self._session_context = None
510
+
511
+ if self._context is not None:
512
+ try:
513
+ await self._context.__aexit__(None, None, None)
514
+ except (RuntimeError, Exception):
515
+ pass # Silently ignore cleanup errors
516
+ self._context = None
517
+ except (RuntimeError, BaseException):
518
+ pass # Silently ignore all cleanup errors
241
519
 
242
520
  self._initialized = False
243
521
 
@@ -290,7 +568,11 @@ class MCPTools(Toolkit):
290
568
  for tool in filtered_tools:
291
569
  try:
292
570
  # Get an entrypoint for the tool
293
- entrypoint = get_entrypoint_for_tool(tool, self.session) # type: ignore
571
+ entrypoint = get_entrypoint_for_tool(
572
+ tool=tool,
573
+ session=self.session, # type: ignore
574
+ mcp_tools_instance=self,
575
+ )
294
576
  # Create a Function for the tool
295
577
  f = Function(
296
578
  name=tool_name_prefix + tool.name,