Skip to content

Commit 4f992d8

Browse files
authored
feat(toolbox-core): add basic implementation (#103)
1 parent b132af5 commit 4f992d8

File tree

10 files changed

+449
-29
lines changed

10 files changed

+449
-29
lines changed

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,12 @@
33

44
# vscode
55
.vscode/
6+
7+
# python
8+
env
9+
venv
10+
*.pyc
11+
.python-version
12+
**.egg-info/
13+
__pycache__/**
14+

packages/toolbox-core/pyproject.toml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ authors = [
99
{name = "Google LLC", email = "[email protected]"}
1010
]
1111

12-
# TODO: Add deps
13-
#dependencies = [
14-
#]
12+
dependencies = [
13+
"pydantic>=2.7.0,<3.0.0",
14+
"aiohttp>=3.8.6,<4.0.0",
15+
]
1516

1617
classifiers = [
1718
"Intended Audience :: Developers",
@@ -43,6 +44,7 @@ test = [
4344
"isort==6.0.1",
4445
"mypy==1.15.0",
4546
"pytest==8.3.5",
47+
"pytest-aioresponses==0.3.0"
4648
]
4749
[build-system]
4850
requires = ["setuptools"]
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
aiohttp==3.11.14
2+
pydantic==2.10.6

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
from .client import DummyClass
15+
from .client import ToolboxClient
1616

17-
__all__ = ["DummyClass"]
17+
__all__ = ["ToolboxClient"]

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

Lines changed: 142 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,153 @@
44
# you may not use this file except in compliance with the License.
55
# You may obtain a copy of the License at
66
#
7-
# http://www.apache.org/licenses/LICENSE-2.0
7+
# http://www.apache.org/licenses/LICENSE-2.0
88
#
99
# Unless required by applicable law or agreed to in writing, software
1010
# distributed under the License is distributed on an "AS IS" BASIS,
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from typing import Optional
1516

16-
class DummyClass:
17-
def __init__(self):
18-
self.val = "dummy value"
17+
from aiohttp import ClientSession
18+
19+
from .protocol import ManifestSchema, ToolSchema
20+
from .tool import ToolboxTool
21+
22+
23+
class ToolboxClient:
24+
"""
25+
An asynchronous client for interacting with a Toolbox service.
26+
27+
Provides methods to discover and load tools defined by a remote Toolbox
28+
service endpoint. It manages an underlying `aiohttp.ClientSession`.
29+
"""
30+
31+
__base_url: str
32+
__session: ClientSession
33+
34+
def __init__(
35+
self,
36+
url: str,
37+
session: Optional[ClientSession] = None,
38+
):
39+
"""
40+
Initializes the ToolboxClient.
41+
42+
Args:
43+
url: The base URL for the Toolbox service API (e.g., "http://localhost:8000").
44+
session: An optional existing `aiohttp.ClientSession` to use.
45+
If None (default), a new session is created internally. Note that
46+
if a session is provided, its lifecycle (including closing)
47+
should typically be managed externally.
48+
"""
49+
self.__base_url = url
50+
51+
# If no aiohttp.ClientSession is provided, make our own
52+
if session is None:
53+
session = ClientSession()
54+
self.__session = session
55+
56+
def __parse_tool(self, name: str, schema: ToolSchema) -> ToolboxTool:
57+
"""Internal helper to create a callable tool from its schema."""
58+
tool = ToolboxTool(
59+
session=self.__session,
60+
base_url=self.__base_url,
61+
name=name,
62+
desc=schema.description,
63+
params=[p.to_param() for p in schema.parameters],
64+
)
65+
return tool
66+
67+
async def __aenter__(self):
68+
"""
69+
Enter the runtime context related to this client instance.
70+
71+
Allows the client to be used as an asynchronous context manager
72+
(e.g., `async with ToolboxClient(...) as client:`).
73+
74+
Returns:
75+
self: The client instance itself.
76+
"""
77+
return self
78+
79+
async def __aexit__(self, exc_type, exc_val, exc_tb):
80+
"""
81+
Exit the runtime context and close the internally managed session.
82+
83+
Allows the client to be used as an asynchronous context manager
84+
(e.g., `async with ToolboxClient(...) as client:`).
85+
"""
86+
await self.close()
87+
88+
async def close(self):
89+
"""
90+
Asynchronously closes the underlying client session. Doing so will cause
91+
any tools created by this Client to cease to function.
92+
93+
If the session was provided externally during initialization, the caller
94+
is responsible for its lifecycle, but calling close here will still
95+
attempt to close it.
96+
"""
97+
await self.__session.close()
98+
99+
async def load_tool(
100+
self,
101+
name: str,
102+
) -> ToolboxTool:
103+
"""
104+
Asynchronously loads a tool from the server.
105+
106+
Retrieves the schema for the specified tool from the Toolbox server and
107+
returns a callable object (`ToolboxTool`) that can be used to invoke the
108+
tool remotely.
109+
110+
Args:
111+
name: The unique name or identifier of the tool to load.
112+
113+
Returns:
114+
ToolboxTool: A callable object representing the loaded tool, ready
115+
for execution. The specific arguments and behavior of the callable
116+
depend on the tool itself.
117+
118+
"""
119+
120+
# request the definition of the tool from the server
121+
url = f"{self.__base_url}/api/tool/{name}"
122+
async with self.__session.get(url) as response:
123+
json = await response.json()
124+
manifest: ManifestSchema = ManifestSchema(**json)
125+
126+
# parse the provided definition to a tool
127+
if name not in manifest.tools:
128+
# TODO: Better exception
129+
raise Exception(f"Tool '{name}' not found!")
130+
tool = self.__parse_tool(name, manifest.tools[name])
131+
132+
return tool
133+
134+
async def load_toolset(
135+
self,
136+
name: str,
137+
) -> list[ToolboxTool]:
138+
"""
139+
Asynchronously fetches a toolset and loads all tools defined within it.
140+
141+
Args:
142+
name: Name of the toolset to load tools.
143+
144+
Returns:
145+
list[ToolboxTool]: A list of callables, one for each tool defined
146+
in the toolset.
147+
"""
148+
# Request the definition of the tool from the server
149+
url = f"{self.__base_url}/api/toolset/{name}"
150+
async with self.__session.get(url) as response:
151+
json = await response.json()
152+
manifest: ManifestSchema = ManifestSchema(**json)
153+
154+
# parse each tools name and schema into a list of ToolboxTools
155+
tools = [self.__parse_tool(n, s) for n, s in manifest.tools.items()]
156+
return tools
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from inspect import Parameter
16+
from typing import Optional, Type
17+
18+
from pydantic import BaseModel
19+
20+
21+
class ParameterSchema(BaseModel):
22+
"""
23+
Schema for a tool parameter.
24+
"""
25+
26+
name: str
27+
type: str
28+
description: str
29+
authSources: Optional[list[str]] = None
30+
items: Optional["ParameterSchema"] = None
31+
32+
def __get_type(self) -> Type:
33+
if self.type == "string":
34+
return str
35+
elif self.type == "integer":
36+
return int
37+
elif self.type == "float":
38+
return float
39+
elif self.type == "boolean":
40+
return bool
41+
elif self.type == "array":
42+
if self.items is None:
43+
raise Exception("Unexpected value: type is 'list' but items is None")
44+
return list[self._items.to_type()] # type: ignore
45+
46+
raise ValueError(f"Unsupported schema type: {self.type}")
47+
48+
def to_param(self) -> Parameter:
49+
return Parameter(
50+
self.name,
51+
Parameter.POSITIONAL_OR_KEYWORD,
52+
annotation=self.__get_type(),
53+
)
54+
55+
56+
class ToolSchema(BaseModel):
57+
"""
58+
Schema for a tool.
59+
"""
60+
61+
description: str
62+
parameters: list[ParameterSchema]
63+
authRequired: list[str] = []
64+
65+
66+
class ManifestSchema(BaseModel):
67+
"""
68+
Schema for the Toolbox manifest.
69+
"""
70+
71+
serverVersion: str
72+
tools: dict[str, ToolSchema]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
16+
from inspect import Parameter, Signature
17+
from typing import Any
18+
19+
from aiohttp import ClientSession
20+
21+
22+
class ToolboxTool:
23+
"""
24+
A callable proxy object representing a specific tool on a remote Toolbox server.
25+
26+
Instances of this class behave like asynchronous functions. When called, they
27+
send a request to the corresponding tool's endpoint on the Toolbox server with
28+
the provided arguments.
29+
30+
It utilizes Python's introspection features (`__name__`, `__doc__`,
31+
`__signature__`, `__annotations__`) so that standard tools like `help()`
32+
and `inspect` work as expected.
33+
"""
34+
35+
__url: str
36+
__session: ClientSession
37+
__signature__: Signature
38+
39+
def __init__(
40+
self,
41+
session: ClientSession,
42+
base_url: str,
43+
name: str,
44+
desc: str,
45+
params: list[Parameter],
46+
):
47+
"""
48+
Initializes a callable that will trigger the tool invocation through the Toolbox server.
49+
50+
Args:
51+
session: The `aiohttp.ClientSession` used for making API requests.
52+
base_url: The base URL of the Toolbox server API.
53+
name: The name of the remote tool.
54+
desc: The description of the remote tool (used as its docstring).
55+
params: A list of `inspect.Parameter` objects defining the tool's
56+
arguments and their types/defaults.
57+
"""
58+
59+
# used to invoke the toolbox API
60+
self.__session = session
61+
self.__url = f"{base_url}/api/tool/{name}/invoke"
62+
63+
# the following properties are set to help anyone that might inspect it determine
64+
self.__name__ = name
65+
self.__doc__ = desc
66+
self.__signature__ = Signature(parameters=params, return_annotation=str)
67+
self.__annotations__ = {p.name: p.annotation for p in params}
68+
# TODO: self.__qualname__ ??
69+
70+
async def __call__(self, *args: Any, **kwargs: Any) -> str:
71+
"""
72+
Asynchronously calls the remote tool with the provided arguments.
73+
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.
76+
77+
Args:
78+
*args: Positional arguments for the tool.
79+
**kwargs: Keyword arguments for the tool.
80+
81+
Returns:
82+
The string result returned by the remote tool execution.
83+
"""
84+
all_args = self.__signature__.bind(*args, **kwargs)
85+
all_args.apply_defaults() # Include default values if not provided
86+
payload = all_args.arguments
87+
88+
async with self.__session.post(
89+
self.__url,
90+
json=payload,
91+
) 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)

0 commit comments

Comments
 (0)