nvidia-nat-agno 1.2.0rc5__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.
- aiq/meta/pypi.md +25 -0
- aiq/plugins/agno/__init__.py +0 -0
- aiq/plugins/agno/llm.py +87 -0
- aiq/plugins/agno/register.py +22 -0
- aiq/plugins/agno/tool_wrapper.py +366 -0
- aiq/plugins/agno/tools/__init__.py +0 -0
- aiq/plugins/agno/tools/register.py +20 -0
- aiq/plugins/agno/tools/serp_api_tool.py +115 -0
- nvidia_nat_agno-1.2.0rc5.dist-info/METADATA +38 -0
- nvidia_nat_agno-1.2.0rc5.dist-info/RECORD +13 -0
- nvidia_nat_agno-1.2.0rc5.dist-info/WHEEL +5 -0
- nvidia_nat_agno-1.2.0rc5.dist-info/entry_points.txt +3 -0
- nvidia_nat_agno-1.2.0rc5.dist-info/top_level.txt +1 -0
aiq/meta/pypi.md
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
<!--
|
2
|
+
SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
3
|
+
SPDX-License-Identifier: Apache-2.0
|
4
|
+
|
5
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
6
|
+
you may not use this file except in compliance with the License.
|
7
|
+
You may obtain a copy of the License at
|
8
|
+
|
9
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
10
|
+
|
11
|
+
Unless required by applicable law or agreed to in writing, software
|
12
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
13
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
14
|
+
See the License for the specific language governing permissions and
|
15
|
+
limitations under the License.
|
16
|
+
-->
|
17
|
+
|
18
|
+

|
19
|
+
|
20
|
+
# NVIDIA NeMo Agent Toolkit Subpackage
|
21
|
+
|
22
|
+
<!-- Note: "Agno" is the official product name despite Vale spelling checker warnings -->
|
23
|
+
This is a subpackage for `Agno` integration in NeMo Agent toolkit.
|
24
|
+
|
25
|
+
For more information about the NVIDIA NeMo Agent toolkit, please visit the [NeMo Agent toolkit GitHub Repo](https://github.com/NVIDIA/NeMo-Agent-Toolkit).
|
File without changes
|
aiq/plugins/agno/llm.py
ADDED
@@ -0,0 +1,87 @@
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
|
16
|
+
import os
|
17
|
+
|
18
|
+
from aiq.builder.builder import Builder
|
19
|
+
from aiq.builder.framework_enum import LLMFrameworkEnum
|
20
|
+
from aiq.cli.register_workflow import register_llm_client
|
21
|
+
from aiq.data_models.retry_mixin import RetryMixin
|
22
|
+
from aiq.llm.nim_llm import NIMModelConfig
|
23
|
+
from aiq.llm.openai_llm import OpenAIModelConfig
|
24
|
+
from aiq.utils.exception_handlers.automatic_retries import patch_with_retry
|
25
|
+
|
26
|
+
|
27
|
+
@register_llm_client(config_type=NIMModelConfig, wrapper_type=LLMFrameworkEnum.AGNO)
|
28
|
+
async def nim_agno(llm_config: NIMModelConfig, builder: Builder):
|
29
|
+
|
30
|
+
from agno.models.nvidia import Nvidia
|
31
|
+
|
32
|
+
config_obj = {
|
33
|
+
**llm_config.model_dump(exclude={"type", "model_name"}, by_alias=True),
|
34
|
+
"id": f"{llm_config.model_name}",
|
35
|
+
}
|
36
|
+
|
37
|
+
# Because Agno uses a different environment variable for the API key, we need to set it here manually
|
38
|
+
if ("api_key" not in config_obj or config_obj["api_key"] is None):
|
39
|
+
|
40
|
+
if ("NVIDIA_API_KEY" in os.environ):
|
41
|
+
# Dont need to do anything. User has already set the correct key
|
42
|
+
pass
|
43
|
+
else:
|
44
|
+
nvidai_api_key = os.getenv("NVIDIA_API_KEY")
|
45
|
+
|
46
|
+
if (nvidai_api_key is not None):
|
47
|
+
# Transfer the key to the correct environment variable
|
48
|
+
os.environ["NVIDIA_API_KEY"] = nvidai_api_key
|
49
|
+
|
50
|
+
# Create Nvidia instance with conditional base_url
|
51
|
+
kwargs = {"id": config_obj.get("id")}
|
52
|
+
if "base_url" in config_obj and config_obj.get("base_url") is not None:
|
53
|
+
kwargs["base_url"] = config_obj.get("base_url")
|
54
|
+
|
55
|
+
client = Nvidia(**kwargs) # type: ignore[arg-type]
|
56
|
+
|
57
|
+
if isinstance(client, RetryMixin):
|
58
|
+
|
59
|
+
client = patch_with_retry(client,
|
60
|
+
retries=llm_config.num_retries,
|
61
|
+
retry_codes=llm_config.retry_on_status_codes,
|
62
|
+
retry_on_messages=llm_config.retry_on_errors)
|
63
|
+
|
64
|
+
yield client
|
65
|
+
|
66
|
+
|
67
|
+
@register_llm_client(config_type=OpenAIModelConfig, wrapper_type=LLMFrameworkEnum.AGNO)
|
68
|
+
async def openai_agno(llm_config: OpenAIModelConfig, builder: Builder):
|
69
|
+
|
70
|
+
from agno.models.openai import OpenAIChat
|
71
|
+
|
72
|
+
# Use model_dump to get the proper field values with correct types
|
73
|
+
kwargs = llm_config.model_dump(exclude={"type"}, by_alias=True)
|
74
|
+
|
75
|
+
# AGNO uses 'id' instead of 'model' for the model name
|
76
|
+
if "model" in kwargs:
|
77
|
+
kwargs["id"] = kwargs.pop("model")
|
78
|
+
|
79
|
+
client = OpenAIChat(**kwargs)
|
80
|
+
|
81
|
+
if isinstance(llm_config, RetryMixin):
|
82
|
+
client = patch_with_retry(client,
|
83
|
+
retries=llm_config.num_retries,
|
84
|
+
retry_codes=llm_config.retry_on_status_codes,
|
85
|
+
retry_on_messages=llm_config.retry_on_errors)
|
86
|
+
|
87
|
+
yield client
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
|
16
|
+
# pylint: disable=unused-import
|
17
|
+
# flake8: noqa
|
18
|
+
# isort:skip_file
|
19
|
+
|
20
|
+
from . import llm
|
21
|
+
from . import tool_wrapper
|
22
|
+
from .tools import register
|
@@ -0,0 +1,366 @@
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
|
16
|
+
import asyncio
|
17
|
+
import json
|
18
|
+
import logging
|
19
|
+
import textwrap
|
20
|
+
import traceback
|
21
|
+
from typing import Any
|
22
|
+
from typing import Awaitable
|
23
|
+
from typing import Callable
|
24
|
+
from typing import List
|
25
|
+
|
26
|
+
from agno.tools import tool
|
27
|
+
|
28
|
+
from aiq.builder.builder import Builder
|
29
|
+
from aiq.builder.framework_enum import LLMFrameworkEnum
|
30
|
+
from aiq.builder.function import Function
|
31
|
+
from aiq.cli.register_workflow import register_tool_wrapper
|
32
|
+
|
33
|
+
logger = logging.getLogger(__name__)
|
34
|
+
|
35
|
+
# Add a module-level dictionary to track tool call counts for each tool
|
36
|
+
_tool_call_counters = {}
|
37
|
+
_MAX_EMPTY_CALLS = 1 # Maximum number of empty/metadata-only calls before signaling a problem
|
38
|
+
# For better UX, stop after just 1 empty call for search tools
|
39
|
+
|
40
|
+
# Dictionary to track which tools have already handled an initialization call
|
41
|
+
_tool_initialization_done = {}
|
42
|
+
|
43
|
+
|
44
|
+
async def process_result(result: Any, name: str) -> str:
|
45
|
+
"""
|
46
|
+
Process the result from a function to ensure it's in the expected format.
|
47
|
+
This function guarantees that the output will be a properly formatted string,
|
48
|
+
suitable for consumption by language models like OpenAI's API.
|
49
|
+
|
50
|
+
Parameters
|
51
|
+
----------
|
52
|
+
result : Any
|
53
|
+
The result to process
|
54
|
+
name : str
|
55
|
+
The name of the tool (for logging)
|
56
|
+
|
57
|
+
Returns
|
58
|
+
-------
|
59
|
+
str: The processed result as a properly formatted string
|
60
|
+
"""
|
61
|
+
logger.debug(f"{name} processing result of type {type(result)}")
|
62
|
+
|
63
|
+
# Handle None or empty results
|
64
|
+
if result is None:
|
65
|
+
logger.warning(f"{name} returned None, converting to empty string")
|
66
|
+
return ""
|
67
|
+
|
68
|
+
# If the result is already a string, validate and return it
|
69
|
+
if isinstance(result, str):
|
70
|
+
logger.debug(f"{name} returning string result directly")
|
71
|
+
# Ensure result is not empty
|
72
|
+
if not result.strip():
|
73
|
+
return f"The {name} tool completed successfully but returned an empty result."
|
74
|
+
return result
|
75
|
+
|
76
|
+
# Handle Agno Agent.arun response objects
|
77
|
+
if hasattr(result, 'content'):
|
78
|
+
logger.debug(f"{name} returning result.content")
|
79
|
+
content = result.content
|
80
|
+
# Make sure content is a string
|
81
|
+
if not isinstance(content, str):
|
82
|
+
logger.debug(f"{name} result.content is not a string, converting")
|
83
|
+
content = str(content)
|
84
|
+
return content
|
85
|
+
|
86
|
+
# Handle OpenAI style responses
|
87
|
+
if hasattr(result, 'choices') and len(result.choices) > 0:
|
88
|
+
if hasattr(result.choices[0], 'message') and hasattr(result.choices[0].message, 'content'):
|
89
|
+
logger.debug(f"{name} returning result.choices[0].message.content")
|
90
|
+
return str(result.choices[0].message.content)
|
91
|
+
elif hasattr(result.choices[0], 'text'):
|
92
|
+
logger.debug(f"{name} returning result.choices[0].text")
|
93
|
+
return str(result.choices[0].text)
|
94
|
+
|
95
|
+
# Handle list of dictionaries by converting to a formatted string
|
96
|
+
if isinstance(result, list):
|
97
|
+
logger.debug(f"{name} converting list to string")
|
98
|
+
if len(result) == 0:
|
99
|
+
return f"The {name} tool returned an empty list."
|
100
|
+
|
101
|
+
if all(isinstance(item, dict) for item in result):
|
102
|
+
logger.debug(f"{name} converting list of dictionaries to string")
|
103
|
+
formatted_result = ""
|
104
|
+
for i, item in enumerate(result, 1):
|
105
|
+
formatted_result += f"Result {i}:\n"
|
106
|
+
for k, v in item.items():
|
107
|
+
formatted_result += f" {k}: {v}\n"
|
108
|
+
formatted_result += "\n"
|
109
|
+
return formatted_result
|
110
|
+
else:
|
111
|
+
# For other lists, convert to a simple list format
|
112
|
+
formatted_result = "Results:\n\n"
|
113
|
+
for i, item in enumerate(result, 1):
|
114
|
+
formatted_result += f"{i}. {str(item)}\n"
|
115
|
+
return formatted_result
|
116
|
+
|
117
|
+
# Handle dictionaries
|
118
|
+
if isinstance(result, dict):
|
119
|
+
logger.debug(f"{name} converting dictionary to string")
|
120
|
+
try:
|
121
|
+
# Try to format as JSON for readability
|
122
|
+
return json.dumps(result, indent=2)
|
123
|
+
except (TypeError, OverflowError):
|
124
|
+
# Fallback to manual formatting if JSON fails
|
125
|
+
formatted_result = "Result:\n\n"
|
126
|
+
for k, v in result.items():
|
127
|
+
formatted_result += f"{k}: {v}\n"
|
128
|
+
return formatted_result
|
129
|
+
|
130
|
+
# For all other types, convert to string
|
131
|
+
logger.debug(f"{name} converting {type(result)} to string")
|
132
|
+
return str(result)
|
133
|
+
|
134
|
+
|
135
|
+
def execute_agno_tool(name: str,
|
136
|
+
coroutine_fn: Callable[..., Awaitable[Any]],
|
137
|
+
required_fields: List[str],
|
138
|
+
loop: asyncio.AbstractEventLoop,
|
139
|
+
**kwargs: Any) -> Any:
|
140
|
+
"""
|
141
|
+
Execute an Agno tool with the given parameters.
|
142
|
+
|
143
|
+
Parameters
|
144
|
+
----------
|
145
|
+
name : str
|
146
|
+
The name of the tool
|
147
|
+
coroutine_fn : Callable
|
148
|
+
The async function to invoke
|
149
|
+
required_fields : List[str]
|
150
|
+
List of required fields for validation
|
151
|
+
loop : asyncio.AbstractEventLoop
|
152
|
+
The event loop to use for async execution
|
153
|
+
**kwargs : Any
|
154
|
+
The arguments to pass to the function
|
155
|
+
|
156
|
+
Returns
|
157
|
+
-------
|
158
|
+
The result of the function execution as a string
|
159
|
+
"""
|
160
|
+
global _tool_call_counters, _tool_initialization_done
|
161
|
+
|
162
|
+
try:
|
163
|
+
logger.debug(f"Running {name} with kwargs: {kwargs}")
|
164
|
+
|
165
|
+
# Initialize counter for this tool if it doesn't exist
|
166
|
+
if name not in _tool_call_counters:
|
167
|
+
_tool_call_counters[name] = 0
|
168
|
+
|
169
|
+
# Track if this tool has already been initialized
|
170
|
+
if name not in _tool_initialization_done:
|
171
|
+
_tool_initialization_done[name] = False
|
172
|
+
|
173
|
+
# Filter out any known reserved keywords or metadata fields that might cause issues
|
174
|
+
# These are typically added by frameworks and not meant for the function itself
|
175
|
+
reserved_keywords = {'type', '_type', 'model_config', 'model_fields', 'model_dump', 'model_dump_json'}
|
176
|
+
filtered_kwargs = {k: v for k, v in kwargs.items() if k not in reserved_keywords}
|
177
|
+
|
178
|
+
# Check if we're only receiving metadata fields (potential infinite loop indicator)
|
179
|
+
only_metadata = len(filtered_kwargs) == 0 and len(kwargs) > 0
|
180
|
+
|
181
|
+
# Check if this is a search api tool with empty query
|
182
|
+
is_search_api = name.lower().endswith("_api_tool")
|
183
|
+
has_empty_query = "query" in filtered_kwargs and (not filtered_kwargs["query"]
|
184
|
+
or filtered_kwargs["query"].strip() == "")
|
185
|
+
|
186
|
+
# Log if we filtered anything
|
187
|
+
filtered_keys = set(kwargs.keys()) - set(filtered_kwargs.keys())
|
188
|
+
if filtered_keys:
|
189
|
+
logger.debug(f"Filtered reserved keywords from kwargs: {filtered_keys}")
|
190
|
+
|
191
|
+
# IMPORTANT: Special handling for SerpApi and other search API calls
|
192
|
+
if is_search_api and (only_metadata or has_empty_query):
|
193
|
+
# If this is the first time this tool is called with empty query, allow it for initialization
|
194
|
+
if not _tool_initialization_done[name]:
|
195
|
+
logger.info(f"First-time initialization call for {name}")
|
196
|
+
_tool_initialization_done[name] = True
|
197
|
+
else:
|
198
|
+
# If we've already initialized this tool, prevent repeated empty calls
|
199
|
+
logger.error(f"Tool {name} called with empty query after initialization. Blocking repeated calls.")
|
200
|
+
return f"ERROR: Tool {name} requires a valid query. Provide a specific search term to continue."
|
201
|
+
|
202
|
+
# IMPORTANT: Safeguard for infinite loops
|
203
|
+
# If we're only getting metadata fields and no actual parameters repeatedly
|
204
|
+
if only_metadata:
|
205
|
+
_tool_call_counters[name] += 1
|
206
|
+
logger.warning(
|
207
|
+
f"Tool {name} called with only metadata fields (call {_tool_call_counters[name]}/{_MAX_EMPTY_CALLS})")
|
208
|
+
|
209
|
+
# Break potential infinite loops after too many metadata-only calls
|
210
|
+
if _tool_call_counters[name] >= _MAX_EMPTY_CALLS:
|
211
|
+
logger.error(
|
212
|
+
f"Detected potential infinite loop for tool {name} - received {_tool_call_counters[name]} calls")
|
213
|
+
_tool_call_counters[name] = 0 # Reset counter
|
214
|
+
return f"ERROR: Tool {name} appears to be in a loop. Provide parameters when calling this tool."
|
215
|
+
else:
|
216
|
+
# Reset counter when we get actual parameters
|
217
|
+
_tool_call_counters[name] = 0
|
218
|
+
|
219
|
+
# Fix for the 'kwargs' wrapper issue - unwrap if needed
|
220
|
+
if len(filtered_kwargs) == 1 and 'kwargs' in filtered_kwargs and isinstance(filtered_kwargs['kwargs'], dict):
|
221
|
+
logger.debug("Detected wrapped kwargs, unwrapping")
|
222
|
+
# If input is {'kwargs': {'actual': 'params'}}, we need to unwrap it
|
223
|
+
unwrapped_kwargs = filtered_kwargs['kwargs']
|
224
|
+
|
225
|
+
# Also filter the unwrapped kwargs
|
226
|
+
unwrapped_kwargs = {k: v for k, v in unwrapped_kwargs.items() if k not in reserved_keywords}
|
227
|
+
|
228
|
+
# Check if we're missing required fields and try to recover
|
229
|
+
for field in required_fields:
|
230
|
+
if field not in unwrapped_kwargs:
|
231
|
+
logger.warning(f"Missing required field '{field}' in unwrapped kwargs: {unwrapped_kwargs}")
|
232
|
+
# Try to build a query from all the provided values if query is required
|
233
|
+
if field == 'query' and len(unwrapped_kwargs) > 0:
|
234
|
+
# Simple fallback for search tools - cobble together a query string
|
235
|
+
query_parts = []
|
236
|
+
for k, v in unwrapped_kwargs.items():
|
237
|
+
query_parts.append(f"{k}: {v}")
|
238
|
+
unwrapped_kwargs['query'] = " ".join(query_parts)
|
239
|
+
logger.info(f"Built fallback query: {unwrapped_kwargs['query']}")
|
240
|
+
|
241
|
+
filtered_kwargs = unwrapped_kwargs
|
242
|
+
|
243
|
+
# Special handling for initialization calls - these are often empty or partial
|
244
|
+
is_initialization = len(filtered_kwargs) == 0
|
245
|
+
|
246
|
+
# Further validation to ensure all required fields are present
|
247
|
+
# If this looks like an initialization call, we'll be more lenient
|
248
|
+
missing_fields = []
|
249
|
+
for field in required_fields:
|
250
|
+
if field not in filtered_kwargs:
|
251
|
+
missing_fields.append(field)
|
252
|
+
logger.warning(f"Missing field '{field}' in kwargs: {filtered_kwargs}")
|
253
|
+
|
254
|
+
# Special handling for search tools - query can be optional during initialization
|
255
|
+
if not is_initialization and missing_fields and "query" in missing_fields and name.lower().endswith(
|
256
|
+
"_api_tool"):
|
257
|
+
logger.info(f"Tool {name} was called without a 'query' parameter, treating as initialization")
|
258
|
+
is_initialization = True
|
259
|
+
|
260
|
+
# Only enforce required fields for non-initialization calls
|
261
|
+
if not is_initialization and missing_fields:
|
262
|
+
if "query" in missing_fields:
|
263
|
+
# Add a specific message for missing query
|
264
|
+
raise ValueError(f"Missing required parameter 'query'. The tool {name} requires a search query.")
|
265
|
+
else:
|
266
|
+
missing_fields_str = ", ".join([f"'{f}'" for f in missing_fields])
|
267
|
+
raise ValueError(f"Missing required parameters: {missing_fields_str} for {name}.")
|
268
|
+
|
269
|
+
logger.debug(f"Invoking function with parameters: {filtered_kwargs}")
|
270
|
+
|
271
|
+
# Try different calling styles to handle both positional and keyword arguments
|
272
|
+
try:
|
273
|
+
# First try calling with kwargs directly - this works for functions that use **kwargs
|
274
|
+
future = asyncio.run_coroutine_threadsafe(coroutine_fn(**filtered_kwargs), loop)
|
275
|
+
result = future.result(timeout=120) # 2-minute timeout
|
276
|
+
except TypeError as e:
|
277
|
+
if "missing 1 required positional argument: 'input_obj'" in str(e):
|
278
|
+
# If we get a specific error about missing positional arg, try passing as positional
|
279
|
+
logger.debug(f"Retrying with positional argument style for {name}")
|
280
|
+
future = asyncio.run_coroutine_threadsafe(coroutine_fn(filtered_kwargs), loop)
|
281
|
+
result = future.result(timeout=120) # 2-minute timeout
|
282
|
+
else:
|
283
|
+
# For other TypeError errors, reraise
|
284
|
+
raise
|
285
|
+
|
286
|
+
# Always process the result to ensure proper formatting, regardless of type
|
287
|
+
process_future = asyncio.run_coroutine_threadsafe(process_result(result, name), loop)
|
288
|
+
return process_future.result(timeout=30) # 30-second timeout for processing
|
289
|
+
|
290
|
+
except Exception as e:
|
291
|
+
logger.exception(f"Error executing Agno tool {name}: {e}")
|
292
|
+
error_traceback = traceback.format_exc()
|
293
|
+
logger.error(f"Exception traceback: {error_traceback}")
|
294
|
+
raise
|
295
|
+
|
296
|
+
|
297
|
+
@register_tool_wrapper(wrapper_type=LLMFrameworkEnum.AGNO)
|
298
|
+
def agno_tool_wrapper(name: str, fn: Function, builder: Builder):
|
299
|
+
"""
|
300
|
+
Wraps an AIQ Toolkit Function to be usable as an Agno tool.
|
301
|
+
|
302
|
+
This wrapper handles the conversion of async AIQ Toolkit functions to
|
303
|
+
the format expected by Agno tools. It properly handles input schema,
|
304
|
+
descriptions, and async invocation.
|
305
|
+
|
306
|
+
Parameters
|
307
|
+
----------
|
308
|
+
name : str
|
309
|
+
The name of the tool
|
310
|
+
fn : Function
|
311
|
+
The AIQ Toolkit Function to wrap
|
312
|
+
builder : Builder
|
313
|
+
The builder instance
|
314
|
+
|
315
|
+
Returns
|
316
|
+
-------
|
317
|
+
A callable that can be used as an Agno tool
|
318
|
+
"""
|
319
|
+
# Ensure input schema is present
|
320
|
+
assert fn.input_schema is not None, "Tool must have input schema"
|
321
|
+
|
322
|
+
# Get the event loop for running async functions
|
323
|
+
try:
|
324
|
+
loop = asyncio.get_running_loop()
|
325
|
+
except RuntimeError:
|
326
|
+
# If there's no running event loop, create a new one
|
327
|
+
loop = asyncio.new_event_loop()
|
328
|
+
asyncio.set_event_loop(loop)
|
329
|
+
|
330
|
+
# Get the async function to invoke
|
331
|
+
coroutine_fn = fn.acall_invoke
|
332
|
+
|
333
|
+
# Extract metadata for the tool
|
334
|
+
description = fn.description or ""
|
335
|
+
if description:
|
336
|
+
description = textwrap.dedent(description).strip()
|
337
|
+
|
338
|
+
# Input schema handling from LangChain-style
|
339
|
+
required_fields = []
|
340
|
+
if fn.input_schema is not None:
|
341
|
+
try:
|
342
|
+
schema_json = fn.input_schema.model_json_schema()
|
343
|
+
required_fields = schema_json.get("required", [])
|
344
|
+
# Add schema description to the tool description if available
|
345
|
+
schema_desc = schema_json.get("description")
|
346
|
+
if schema_desc and schema_desc not in description:
|
347
|
+
description = f"{description}\n\nArguments: {schema_desc}"
|
348
|
+
except Exception as e:
|
349
|
+
logger.warning(f"Error extracting JSON schema from input_schema: {e}")
|
350
|
+
|
351
|
+
# Create a function specific to this tool with proper closure variables
|
352
|
+
def tool_sync_wrapper(**kwargs: Any) -> Any:
|
353
|
+
"""Synchronous implementation of the tool function."""
|
354
|
+
return execute_agno_tool(name, coroutine_fn, required_fields, loop, **kwargs)
|
355
|
+
|
356
|
+
# Prepare the documentation for the tool
|
357
|
+
if description:
|
358
|
+
tool_sync_wrapper.__doc__ = description
|
359
|
+
|
360
|
+
# Set the function name
|
361
|
+
tool_sync_wrapper.__name__ = name
|
362
|
+
|
363
|
+
# Apply the tool decorator and return it
|
364
|
+
decorated_tool = tool(name=name, description=description)(tool_sync_wrapper)
|
365
|
+
|
366
|
+
return decorated_tool
|
File without changes
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
|
16
|
+
# pylint: disable=unused-import
|
17
|
+
# flake8: noqa
|
18
|
+
# isort:skip_file
|
19
|
+
|
20
|
+
from . import serp_api_tool
|
@@ -0,0 +1,115 @@
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
|
16
|
+
import logging
|
17
|
+
import os
|
18
|
+
|
19
|
+
from pydantic import Field
|
20
|
+
|
21
|
+
from aiq.builder.builder import Builder
|
22
|
+
from aiq.builder.framework_enum import LLMFrameworkEnum
|
23
|
+
from aiq.builder.function_info import FunctionInfo
|
24
|
+
from aiq.cli.register_workflow import register_function
|
25
|
+
from aiq.data_models.function import FunctionBaseConfig
|
26
|
+
|
27
|
+
logger = logging.getLogger(__name__)
|
28
|
+
|
29
|
+
|
30
|
+
class SerpApiToolConfig(FunctionBaseConfig, name="serp_api_tool"):
|
31
|
+
"""
|
32
|
+
Tool that retrieves search results from the web using SerpAPI.
|
33
|
+
Requires a SERP_API_KEY.
|
34
|
+
"""
|
35
|
+
api_key: str | None = Field(default=None, description="The API key for the SerpAPI service.")
|
36
|
+
max_results: int = Field(default=5, description="The maximum number of results to return.")
|
37
|
+
|
38
|
+
|
39
|
+
@register_function(config_type=SerpApiToolConfig, framework_wrappers=[LLMFrameworkEnum.AGNO])
|
40
|
+
async def serp_api_tool(tool_config: SerpApiToolConfig, builder: Builder):
|
41
|
+
"""Create a SerpAPI search tool for use with Agno.
|
42
|
+
|
43
|
+
This creates a search function that uses SerpAPI to search the web.
|
44
|
+
|
45
|
+
Args:
|
46
|
+
tool_config (SerpApiToolConfig): Configuration for the SerpAPI tool.
|
47
|
+
builder (Builder): The AIQ Toolkit builder instance.
|
48
|
+
|
49
|
+
Returns:
|
50
|
+
FunctionInfo: A FunctionInfo object wrapping the SerpAPI search functionality.
|
51
|
+
"""
|
52
|
+
import json
|
53
|
+
|
54
|
+
from agno.tools.serpapi import SerpApiTools
|
55
|
+
|
56
|
+
if (not tool_config.api_key):
|
57
|
+
tool_config.api_key = os.getenv("SERP_API_KEY")
|
58
|
+
|
59
|
+
if not tool_config.api_key:
|
60
|
+
raise ValueError(
|
61
|
+
"API token must be provided in the configuration or in the environment variable `SERP_API_KEY`")
|
62
|
+
|
63
|
+
# Create the SerpAPI tools instance
|
64
|
+
search_tool = SerpApiTools(api_key=tool_config.api_key)
|
65
|
+
|
66
|
+
# Simple search function with a single string parameter
|
67
|
+
async def _serp_api_search(query: str) -> str:
|
68
|
+
"""
|
69
|
+
Search the web using SerpAPI.
|
70
|
+
|
71
|
+
Args:
|
72
|
+
query (str): The search query to perform. If empty, returns initialization message.
|
73
|
+
|
74
|
+
Returns:
|
75
|
+
str: Formatted search results or initialization message.
|
76
|
+
"""
|
77
|
+
|
78
|
+
if not query or query.strip() == "":
|
79
|
+
exception_msg = "Search query cannot be empty. Please provide a specific search term to continue."
|
80
|
+
logger.warning(exception_msg)
|
81
|
+
return exception_msg
|
82
|
+
|
83
|
+
logger.info("Searching SerpAPI with query: '%s', max_results: %s", query, tool_config.max_results)
|
84
|
+
|
85
|
+
try:
|
86
|
+
# Perform the search
|
87
|
+
raw_all_results: str = search_tool.search_google(query=query, num_results=tool_config.max_results)
|
88
|
+
all_results: dict = json.loads(raw_all_results)
|
89
|
+
search_results = all_results.get('search_results', [])
|
90
|
+
|
91
|
+
logger.info("SerpAPI returned %s results", len(search_results))
|
92
|
+
|
93
|
+
# Format the results as a string
|
94
|
+
formatted_results = []
|
95
|
+
for result in search_results:
|
96
|
+
title = result.get('title', 'No Title')
|
97
|
+
link = result.get('link', 'No Link')
|
98
|
+
snippet = result.get('snippet', 'No Snippet')
|
99
|
+
|
100
|
+
formatted_result = f'<Document href="{link}"/>\n'
|
101
|
+
formatted_result += f'# {title}\n\n'
|
102
|
+
formatted_result += f'{snippet}\n'
|
103
|
+
formatted_result += '</Document>'
|
104
|
+
formatted_results.append(formatted_result)
|
105
|
+
|
106
|
+
return "\n\n---\n\n".join(formatted_results)
|
107
|
+
except Exception as e:
|
108
|
+
logger.exception("Error searching with SerpAPI: %s", e)
|
109
|
+
return f"Error performing search: {str(e)}"
|
110
|
+
|
111
|
+
fn_info = FunctionInfo.from_fn(
|
112
|
+
_serp_api_search,
|
113
|
+
description="""This tool searches the web using SerpAPI and returns relevant results for the given query.""")
|
114
|
+
|
115
|
+
yield fn_info
|
@@ -0,0 +1,38 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: nvidia-nat-agno
|
3
|
+
Version: 1.2.0rc5
|
4
|
+
Summary: Subpackage for Agno integration in AIQtoolkit
|
5
|
+
Keywords: ai,rag,agents
|
6
|
+
Classifier: Programming Language :: Python
|
7
|
+
Requires-Python: <3.13,>=3.11
|
8
|
+
Description-Content-Type: text/markdown
|
9
|
+
Requires-Dist: nvidia-nat~=1.2
|
10
|
+
Requires-Dist: agno~=1.2.3
|
11
|
+
Requires-Dist: openai~=1.66
|
12
|
+
Requires-Dist: google-search-results~=2.4.2
|
13
|
+
|
14
|
+
<!--
|
15
|
+
SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
16
|
+
SPDX-License-Identifier: Apache-2.0
|
17
|
+
|
18
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
19
|
+
you may not use this file except in compliance with the License.
|
20
|
+
You may obtain a copy of the License at
|
21
|
+
|
22
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
23
|
+
|
24
|
+
Unless required by applicable law or agreed to in writing, software
|
25
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
26
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
27
|
+
See the License for the specific language governing permissions and
|
28
|
+
limitations under the License.
|
29
|
+
-->
|
30
|
+
|
31
|
+

|
32
|
+
|
33
|
+
# NVIDIA NeMo Agent Toolkit Subpackage
|
34
|
+
|
35
|
+
<!-- Note: "Agno" is the official product name despite Vale spelling checker warnings -->
|
36
|
+
This is a subpackage for `Agno` integration in NeMo Agent toolkit.
|
37
|
+
|
38
|
+
For more information about the NVIDIA NeMo Agent toolkit, please visit the [NeMo Agent toolkit GitHub Repo](https://github.com/NVIDIA/NeMo-Agent-Toolkit).
|
@@ -0,0 +1,13 @@
|
|
1
|
+
aiq/meta/pypi.md,sha256=r3EME9Ia3YUpqw1R78wNcQqKdUnngGvv-EhrPOAadss,1209
|
2
|
+
aiq/plugins/agno/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
|
+
aiq/plugins/agno/llm.py,sha256=d08EfB4xOFwNh6cgaAoFPBiXVSofazd_puZRK1oO8Zg,3434
|
4
|
+
aiq/plugins/agno/register.py,sha256=6vC1TjMxo3igqTnEtVFgLEf_jgLYkBfBZxjwqxGng6w,820
|
5
|
+
aiq/plugins/agno/tool_wrapper.py,sha256=bWsCdx6IkzOZbvBYC30N5mvGnHNVBScdHji6Mj9amB0,15939
|
6
|
+
aiq/plugins/agno/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
7
|
+
aiq/plugins/agno/tools/register.py,sha256=OCmzR03CHmQHm34ZEascM1dRVh-ALMs2mafDcqLDz6s,775
|
8
|
+
aiq/plugins/agno/tools/serp_api_tool.py,sha256=gTLWwsDmkzmJ6Zp-OzupbwA6t_neSCGrzKdrGQcpIkg,4450
|
9
|
+
nvidia_nat_agno-1.2.0rc5.dist-info/METADATA,sha256=vXGqLNWBK_gbTVaHoVKatQ7cJ-9W8qwEAhtBoBoCZo8,1594
|
10
|
+
nvidia_nat_agno-1.2.0rc5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
11
|
+
nvidia_nat_agno-1.2.0rc5.dist-info/entry_points.txt,sha256=iMjZt0Aziz5vGiuTfSNjy1gWeOj2URFhhKt-gti-9sY,103
|
12
|
+
nvidia_nat_agno-1.2.0rc5.dist-info/top_level.txt,sha256=fo7AzYcNhZ_tRWrhGumtxwnxMew4xrT1iwouDy_f0Kc,4
|
13
|
+
nvidia_nat_agno-1.2.0rc5.dist-info/RECORD,,
|
@@ -0,0 +1 @@
|
|
1
|
+
aiq
|