13
13
# limitations under the License.
14
14
15
15
16
+ import warnings
16
17
from inspect import Parameter , Signature
17
- from typing import Any
18
+ from typing import Any , Callable , TypeVar , Union
18
19
19
20
from aiohttp import ClientSession
20
21
22
+ T = TypeVar ("T" , bound = "ToolboxTool" )
23
+
21
24
22
25
class ToolboxTool :
23
26
"""
24
27
A callable proxy object representing a specific tool on a remote Toolbox server.
25
28
26
29
Instances of this class behave like asynchronous functions. When called, they
27
30
send a request to the corresponding tool's endpoint on the Toolbox server with
28
- the provided arguments.
31
+ the provided arguments, including any bound parameters.
32
+
33
+ Methods like `bind_param` return *new* instances
34
+ with the added state, ensuring immutability of the original tool object.
29
35
30
36
It utilizes Python's introspection features (`__name__`, `__doc__`,
31
37
`__signature__`, `__annotations__`) so that standard tools like `help()`
@@ -43,6 +49,7 @@ def __init__(
43
49
name : str ,
44
50
desc : str ,
45
51
params : list [Parameter ],
52
+ bound_params : dict [str , Union [Any , Callable [[], Any ]]] | None = None ,
46
53
):
47
54
"""
48
55
Initializes a callable that will trigger the tool invocation through the Toolbox server.
@@ -54,43 +61,217 @@ def __init__(
54
61
desc: The description of the remote tool (used as its docstring).
55
62
params: A list of `inspect.Parameter` objects defining the tool's
56
63
arguments and their types/defaults.
64
+ bound_params: Pre-existing bound parameters.
57
65
"""
66
+ self .__base_url = base_url
58
67
59
68
# used to invoke the toolbox API
60
69
self .__session = session
61
70
self .__url = f"{ base_url } /api/tool/{ name } /invoke"
71
+ self .__original_params = params
72
+
73
+ # Store bound params
74
+ self .__bound_params = bound_params or {}
75
+
76
+ # Filter out bound parameters from the signature exposed to the user
77
+ visible_params = [p for p in params if p .name not in self .__bound_params ]
62
78
63
79
# the following properties are set to help anyone that might inspect it determine
64
80
self .__name__ = name
65
81
self .__doc__ = desc
66
- self .__signature__ = Signature (parameters = params , return_annotation = str )
67
- self .__annotations__ = {p .name : p .annotation for p in params }
82
+ # The signature only shows non-bound parameters
83
+ self .__signature__ = Signature (parameters = visible_params , return_annotation = str )
84
+ self .__annotations__ = {p .name : p .annotation for p in visible_params }
68
85
# TODO: self.__qualname__ ??
69
86
70
87
async def __call__ (self , * args : Any , ** kwargs : Any ) -> str :
71
88
"""
72
- Asynchronously calls the remote tool with the provided arguments.
89
+ Asynchronously calls the remote tool with the provided arguments and bound parameters .
73
90
74
- Validates arguments against the tool's signature, then sends them
75
- as a JSON payload in a POST request to the tool's invoke URL.
91
+ Validates arguments against the tool's signature (excluding bound parameters),
92
+ then sends bound parameters and call arguments as a JSON payload in a POST request to the tool's invoke URL.
76
93
77
94
Args:
78
- *args: Positional arguments for the tool.
79
- **kwargs: Keyword arguments for the tool.
95
+ *args: Positional arguments for the tool (for non-bound parameters) .
96
+ **kwargs: Keyword arguments for the tool (for non-bound parameters) .
80
97
81
98
Returns:
82
99
The string result returned by the remote tool execution.
100
+
101
+ Raises:
102
+ TypeError: If a bound parameter conflicts with a parameter provided at call time.
103
+ Exception: If the remote tool call results in an error.
83
104
"""
84
- all_args = self .__signature__ .bind (* args , ** kwargs )
85
- all_args .apply_defaults () # Include default values if not provided
86
- payload = all_args .arguments
105
+ # Resolve bound parameters by evaluating callables
106
+ resolved_bound_params : dict [str , Any ] = {}
107
+ for name , value_or_callable in self .__bound_params .items ():
108
+ try :
109
+ resolved_bound_params [name ] = (
110
+ value_or_callable ()
111
+ if callable (value_or_callable )
112
+ else value_or_callable
113
+ )
114
+ except Exception as e :
115
+ raise RuntimeError (
116
+ f"Error evaluating bound parameter '{ name } ' for tool '{ self .__name__ } ': { e } "
117
+ ) from e
118
+
119
+ # Check for conflicts between resolved bound params and kwargs
120
+ conflicts = resolved_bound_params .keys () & kwargs .keys ()
121
+ if conflicts :
122
+ raise TypeError (
123
+ f"Tool '{ self .__name__ } ': Cannot provide value during call for already bound argument(s): { ', ' .join (conflicts )} "
124
+ )
125
+ merged_kwargs = {** resolved_bound_params , ** kwargs }
87
126
127
+ # Bind *args and merged_kwargs using the *original* full signature
128
+ # This ensures all parameters (bound and call-time) are accounted for.
129
+ full_signature = Signature (
130
+ parameters = self .__original_params , return_annotation = str
131
+ )
132
+ try :
133
+ # We use merged_kwargs here; args fill positional slots first.
134
+ # Bound parameters passed positionally via *args is complex and less intuitive,
135
+ # so we primarily expect bound params to be treated like pre-filled keywords.
136
+ # If a user *really* wanted to bind a purely positional param, they could,
137
+ # but providing it again via *args at call time would be an error caught by bind().
138
+ all_args = full_signature .bind (* args , ** merged_kwargs )
139
+ except TypeError as e :
140
+ raise TypeError (
141
+ f"Argument binding error for tool '{ self .__name__ } ' (check bound params and call arguments): { e } "
142
+ ) from e
143
+
144
+ all_args .apply_defaults ()
145
+
146
+ # Make the API call
88
147
async with self .__session .post (
89
148
self .__url ,
90
- json = payload ,
149
+ payload = all_args . arguments ,
91
150
) as resp :
92
- ret = await resp .json ()
93
- if "error" in ret :
94
- # TODO: better error
95
- raise Exception (ret ["error" ])
96
- return ret .get ("result" , ret )
151
+ try :
152
+ ret = await resp .json ()
153
+ except Exception as e :
154
+ raise Exception (
155
+ f"Failed to decode JSON response from tool '{ self .__name__ } ': { e } . Status: { resp .status } , Body: { await resp .text ()} "
156
+ ) from e
157
+
158
+ if resp .status >= 400 or "error" in ret :
159
+ error_detail = ret .get ("error" , ret ) if isinstance (ret , dict ) else ret
160
+ raise Exception (
161
+ f"Tool '{ self .__name__ } ' invocation failed with status { resp .status } : { error_detail } "
162
+ )
163
+
164
+ # Handle cases where 'result' might be missing but no explicit error given
165
+ return ret .get (
166
+ "result" , str (ret )
167
+ ) # Return string representation if 'result' key missing
168
+
169
+ # # --- Methods for adding state (return new instances) ---
170
+ # def _copy_with_updates(
171
+ # self: T,
172
+ # *,
173
+ # add_bound_params: dict[str, Union[Any, Callable[[], Any]]] | None = None,
174
+ # ) -> T:
175
+ # """Creates a new instance with updated bound params."""
176
+ # new_bound_params = self.__bound_params.copy()
177
+ # if add_bound_params:
178
+ # new_bound_params.update(add_bound_params)
179
+ #
180
+ # return self.__class__(
181
+ # session=self.__session,
182
+ # base_url=self.__base_url,
183
+ # name=self.__name__,
184
+ # desc=self.__doc__ or "",
185
+ # params=self.__original_params,
186
+ # _bound_params=new_bound_params,
187
+ # )
188
+ #
189
+ # def bind_params(
190
+ # self: T,
191
+ # params_to_bind: dict[str, Union[Any, Callable[[], Any]]],
192
+ # strict: bool = True,
193
+ # ) -> T:
194
+ # """
195
+ # Returns a *new* tool instance with the provided parameters bound.
196
+ #
197
+ # Bound parameters are pre-filled values or callables that resolve to values
198
+ # when the tool is called. They are not part of the signature of the
199
+ # returned tool instance.
200
+ #
201
+ # Args:
202
+ # params_to_bind: A dictionary mapping parameter names to their
203
+ # values or callables that return the value.
204
+ # strict: If True (default), raises ValueError if attempting to bind
205
+ # a parameter that doesn't exist in the original tool signature
206
+ # or is already bound in this instance. If False, issues a warning.
207
+ #
208
+ # Returns:
209
+ # A new ToolboxTool instance with the specified parameters bound.
210
+ #
211
+ # Raises:
212
+ # ValueError: If strict is True and a parameter name is invalid or
213
+ # already bound.
214
+ # """
215
+ # invalid_params: list[str] = []
216
+ # duplicate_params: list[str] = []
217
+ # original_param_names = {p.name for p in self.__original_params}
218
+ #
219
+ # for name in params_to_bind:
220
+ # if name not in original_param_names:
221
+ # invalid_params.append(name)
222
+ # elif name in self.__bound_params:
223
+ # duplicate_params.append(name)
224
+ #
225
+ # messages: list[str] = []
226
+ # if invalid_params:
227
+ # messages.append(
228
+ # f"Parameter(s) {', '.join(invalid_params)} do not exist in the signature for tool '{self.__name__}'."
229
+ # )
230
+ # if duplicate_params:
231
+ # messages.append(
232
+ # f"Parameter(s) {', '.join(duplicate_params)} are already bound in this instance of tool '{self.__name__}'."
233
+ # )
234
+ #
235
+ # if messages:
236
+ # message = "\n".join(messages)
237
+ # if strict:
238
+ # raise ValueError(message)
239
+ # else:
240
+ # warnings.warn(message)
241
+ # # Filter out problematic params if not strict
242
+ # params_to_bind = {
243
+ # k: v
244
+ # for k, v in params_to_bind.items()
245
+ # if k not in invalid_params and k not in duplicate_params
246
+ # }
247
+ #
248
+ # if not params_to_bind:
249
+ # return self
250
+ #
251
+ # return self._copy_with_updates(add_bound_params=params_to_bind)
252
+ #
253
+ # def bind_param(
254
+ # self: T,
255
+ # param_name: str,
256
+ # param_value: Union[Any, Callable[[], Any]],
257
+ # strict: bool = True,
258
+ # ) -> T:
259
+ # """
260
+ # Returns a *new* tool instance with the provided parameter bound.
261
+ #
262
+ # Convenience method for binding a single parameter.
263
+ #
264
+ # Args:
265
+ # param_name: The name of the parameter to bind.
266
+ # param_value: The value or callable for the parameter.
267
+ # strict: If True (default), raises ValueError if the parameter name
268
+ # is invalid or already bound. If False, issues a warning.
269
+ #
270
+ # Returns:
271
+ # A new ToolboxTool instance with the specified parameter bound.
272
+ #
273
+ # Raises:
274
+ # ValueError: If strict is True and the parameter name is invalid or
275
+ # already bound.
276
+ # """
277
+ # return self.bind_params({param_name: param_value}, strict=strict)
0 commit comments