Skip to content

Commit d7a852b

Browse files
committed
initial bound params code
1 parent 5374670 commit d7a852b

File tree

1 file changed

+199
-18
lines changed
  • packages/toolbox-core/src/toolbox_core

1 file changed

+199
-18
lines changed

packages/toolbox-core/src/toolbox_core/tool.py

Lines changed: 199 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,25 @@
1313
# limitations under the License.
1414

1515

16+
import warnings
1617
from inspect import Parameter, Signature
17-
from typing import Any
18+
from typing import Any, Callable, TypeVar, Union
1819

1920
from aiohttp import ClientSession
2021

22+
T = TypeVar("T", bound="ToolboxTool")
23+
2124

2225
class ToolboxTool:
2326
"""
2427
A callable proxy object representing a specific tool on a remote Toolbox server.
2528
2629
Instances of this class behave like asynchronous functions. When called, they
2730
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.
2935
3036
It utilizes Python's introspection features (`__name__`, `__doc__`,
3137
`__signature__`, `__annotations__`) so that standard tools like `help()`
@@ -43,6 +49,7 @@ def __init__(
4349
name: str,
4450
desc: str,
4551
params: list[Parameter],
52+
bound_params: dict[str, Union[Any, Callable[[], Any]]] | None = None,
4653
):
4754
"""
4855
Initializes a callable that will trigger the tool invocation through the Toolbox server.
@@ -54,43 +61,217 @@ def __init__(
5461
desc: The description of the remote tool (used as its docstring).
5562
params: A list of `inspect.Parameter` objects defining the tool's
5663
arguments and their types/defaults.
64+
bound_params: Pre-existing bound parameters.
5765
"""
66+
self.__base_url = base_url
5867

5968
# used to invoke the toolbox API
6069
self.__session = session
6170
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]
6278

6379
# the following properties are set to help anyone that might inspect it determine
6480
self.__name__ = name
6581
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}
6885
# TODO: self.__qualname__ ??
6986

7087
async def __call__(self, *args: Any, **kwargs: Any) -> str:
7188
"""
72-
Asynchronously calls the remote tool with the provided arguments.
89+
Asynchronously calls the remote tool with the provided arguments and bound parameters.
7390
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.
7693
7794
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).
8097
8198
Returns:
8299
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.
83104
"""
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}
87126

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
88147
async with self.__session.post(
89148
self.__url,
90-
json=payload,
149+
payload=all_args.arguments,
91150
) 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

Comments
 (0)