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.
- agno/agent/agent.py +26 -1
- agno/agent/remote.py +233 -72
- 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/postgres/async_postgres.py +162 -40
- agno/db/postgres/postgres.py +181 -31
- agno/db/postgres/utils.py +6 -2
- agno/eval/agent_as_judge.py +24 -14
- agno/knowledge/chunking/document.py +3 -2
- agno/knowledge/chunking/markdown.py +8 -3
- agno/knowledge/chunking/recursive.py +2 -2
- agno/knowledge/embedder/mistral.py +1 -1
- 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 +2 -2
- agno/os/routers/evals/evals.py +0 -9
- agno/os/routers/evals/utils.py +6 -6
- agno/os/routers/knowledge/knowledge.py +3 -3
- agno/os/routers/teams/router.py +2 -2
- agno/os/routers/workflows/router.py +2 -2
- 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 +105 -8
- agno/run/agent.py +19 -19
- agno/run/team.py +19 -19
- 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 +219 -59
- agno/team/team.py +22 -2
- agno/tools/mcp/mcp.py +299 -17
- agno/tools/mcp/multi_mcp.py +269 -14
- 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 +172 -32
- agno/workflow/router.py +4 -1
- agno/workflow/steps.py +4 -0
- {agno-2.3.20.dist-info → agno-2.3.22.dist-info}/METADATA +59 -130
- {agno-2.3.20.dist-info → agno-2.3.22.dist-info}/RECORD +58 -44
- {agno-2.3.20.dist-info → agno-2.3.22.dist-info}/WHEEL +0 -0
- {agno-2.3.20.dist-info → agno-2.3.22.dist-info}/licenses/LICENSE +0 -0
- {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"] =
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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(
|
|
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,
|