Skip to content

Commit eb76680

Browse files
authored
fix(toolbox-core): Use typing.Annotated for reliable parameter descriptions instead of docstrings (#371)
* fix(toolbox-core): Use typing.Annotated for reliable parameter descriptions instead of docstrings # Overview This PR refactors how `toolbox-core` handles tool parameter descriptions, moving them from dynamically generated docstrings directly into the function signature itself using `typing.Annotated`. # Problem Previously, our approach involved using the `create_func_docstring` utility to dynamically build a Google-style `Args:` section within the ToolboxTool function's docstring. This method was unreliable and required custom logic just to format correctly (#369). We found this approach to be extremely flaky, as it depended entirely on downstream orchestrators (like LangChain and LlamaIndex) to successfully parse that specific docstring format. This led to bugs, such as orchestrations showing parameter descriptions twice, or (as seen specifically with LlamaIndex) failing to parse the descriptions at all. # Solution This PR implements a much cleaner and more reliable solution. We now modify ParameterSchema.__get_type (in protocol.py) to wrap the base Python type directly with typing.Annotated, passing the parameter description as the second argument. For example, a parameter signature that was previously just: ```py param1: str (with the description hidden in the docstring) ``` ...is now natively represented in the signature as: ```py param1: Annotated[str, "The description of param1"] ``` This is the standard, modern Python way to attach metadata to types. Frameworks like LlamaIndex and LangChain (via Pydantic) are built to introspect Annotated natively, ensuring descriptions are picked up reliably without buggy docstring parsing. As an added benefit, this simplifies the codebase significantly, allowing us to remove the custom create_func_docstring utility and all of its associated test cases. > [!NOTE] > This feature is available as our minimum supported version is Python 3.9, where `Annotated` was introduced ([PEP 593](https://peps.python.org/pep-0593/)). * chore: delint * chore: Fix unit test case * chore: Fix integration tests * chore: Delint * chore: Fix integration tests * chore: Fix integration tests * chore: Fix unit test case * chore: Update internal method name
1 parent fe35082 commit eb76680

File tree

8 files changed

+161
-207
lines changed

8 files changed

+161
-207
lines changed

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

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

1515
from inspect import Parameter
16-
from typing import Any, Optional, Type, Union
16+
from typing import Annotated, Any, Optional, Type, Union
1717

1818
from pydantic import BaseModel
1919

@@ -60,12 +60,12 @@ class ParameterSchema(BaseModel):
6060
items: Optional["ParameterSchema"] = None
6161
additionalProperties: Optional[Union[bool, AdditionalPropertiesSchema]] = None
6262

63-
def __get_type(self) -> Type:
64-
base_type: Type
63+
def __get_annotation(self) -> Any:
64+
base_type: Any
6565
if self.type == "array":
6666
if self.items is None:
6767
raise ValueError("Unexpected value: type is 'array' but items is None")
68-
base_type = list[self.items.__get_type()] # type: ignore
68+
base_type = list[self.items.__get_annotation()] # type: ignore
6969
elif self.type == "object":
7070
if isinstance(self.additionalProperties, AdditionalPropertiesSchema):
7171
value_type = self.additionalProperties.get_value_type()
@@ -76,15 +76,15 @@ def __get_type(self) -> Type:
7676
base_type = _get_python_type(self.type)
7777

7878
if not self.required:
79-
return Optional[base_type] # type: ignore
79+
base_type = Optional[base_type]
8080

81-
return base_type
81+
return Annotated[base_type, self.description]
8282

8383
def to_param(self) -> Parameter:
8484
return Parameter(
8585
self.name,
8686
Parameter.POSITIONAL_OR_KEYWORD,
87-
annotation=self.__get_type(),
87+
annotation=self.__get_annotation(),
8888
default=Parameter.empty if self.required else None,
8989
)
9090

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424

2525
from .protocol import ParameterSchema
2626
from .utils import (
27-
create_func_docstring,
2827
identify_auth_requirements,
2928
params_to_pydantic_model,
3029
resolve_value,
@@ -101,7 +100,7 @@ def __init__(
101100

102101
# the following properties are set to help anyone that might inspect it determine usage
103102
self.__name__ = name
104-
self.__doc__ = create_func_docstring(self.__description, self.__params)
103+
self.__doc__ = self.__description
105104
self.__signature__ = Signature(
106105
parameters=inspect_type_params, return_annotation=str
107106
)

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

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,6 @@
3131
from toolbox_core.protocol import ParameterSchema
3232

3333

34-
def create_func_docstring(description: str, params: Sequence[ParameterSchema]) -> str:
35-
"""Convert tool description and params into its function docstring"""
36-
docstring = description
37-
if not params:
38-
return docstring
39-
docstring += "\n\nArgs:"
40-
for p in params:
41-
annotation = p.to_param().annotation
42-
docstring += f"\n {p.name} ({getattr(annotation, '__name__', str(annotation))}): {p.description}"
43-
return docstring
44-
45-
4634
def identify_auth_requirements(
4735
req_authn_params: Mapping[str, list[str]],
4836
req_authz_tokens: Sequence[str],

packages/toolbox-core/tests/test_client.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
import inspect
1717
import json
18-
from typing import Any, Callable, Mapping, Optional
18+
from typing import Annotated, Any, Callable, Mapping, Optional, get_args, get_origin
1919
from unittest.mock import AsyncMock, Mock
2020

2121
import pytest
@@ -192,15 +192,30 @@ async def test_load_tool_success(aioresponses, test_tool_str):
192192
assert callable(loaded_tool)
193193
# Assert introspection attributes are set correctly
194194
assert loaded_tool.__name__ == TOOL_NAME
195-
expected_description = (
196-
test_tool_str.description
197-
+ f"\n\nArgs:\n param1 (str): Description of Param1"
198-
)
195+
expected_description = test_tool_str.description
199196
assert loaded_tool.__doc__ == expected_description
200197

201-
# Assert signature inspection
198+
# Assert signature inspection, including annotated descriptions
202199
sig = inspect.signature(loaded_tool)
203-
assert list(sig.parameters.keys()) == [p.name for p in test_tool_str.parameters]
200+
actual_params_map = sig.parameters
201+
expected_params_list = test_tool_str.parameters
202+
203+
assert len(actual_params_map) == len(expected_params_list)
204+
205+
for expected_param_schema in expected_params_list:
206+
param_name = expected_param_schema.name
207+
assert param_name in actual_params_map
208+
actual_param = actual_params_map[param_name]
209+
annotation = actual_param.annotation
210+
assert get_origin(annotation) is Annotated
211+
annotation_args = get_args(annotation)
212+
actual_param_description = annotation_args[1]
213+
assert actual_param_description == expected_param_schema.description
214+
215+
if expected_param_schema.required:
216+
assert actual_param.default is inspect.Parameter.empty
217+
else:
218+
assert actual_param.default is None
204219

205220
assert await loaded_tool("some value") == "ok"
206221

packages/toolbox-core/tests/test_e2e.py

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

1515
from inspect import Parameter, signature
16-
from typing import Any, Optional
16+
from typing import Annotated, Any, Optional
1717

1818
import pytest
1919
import pytest_asyncio
@@ -243,15 +243,24 @@ async def test_tool_signature_is_correct(self, toolbox: ToolboxClient):
243243

244244
# The required parameter should have no default
245245
assert sig.parameters["email"].default is Parameter.empty
246-
assert sig.parameters["email"].annotation is str
246+
assert (
247+
sig.parameters["email"].annotation
248+
is Annotated[str, "The email to search for."]
249+
)
247250

248251
# The optional parameter should have a default of None
249252
assert sig.parameters["data"].default is None
250-
assert sig.parameters["data"].annotation is Optional[str]
253+
assert (
254+
sig.parameters["data"].annotation
255+
is Annotated[Optional[str], "The row to narrow down the search."]
256+
)
251257

252258
# The optional parameter should have a default of None
253259
assert sig.parameters["id"].default is None
254-
assert sig.parameters["id"].annotation is Optional[int]
260+
assert (
261+
sig.parameters["id"].annotation
262+
is Annotated[Optional[int], "The id to narrow down the search."]
263+
)
255264

256265
async def test_run_tool_with_optional_params_omitted(self, toolbox: ToolboxClient):
257266
"""Invoke a tool providing only the required parameter."""
@@ -395,15 +404,27 @@ async def test_tool_signature_with_map_params(self, toolbox: ToolboxClient):
395404
sig = signature(tool)
396405

397406
assert "execution_context" in sig.parameters
398-
assert sig.parameters["execution_context"].annotation == dict[str, Any]
407+
assert (
408+
sig.parameters["execution_context"].annotation
409+
== Annotated[
410+
dict[str, Any],
411+
"A flexible set of key-value pairs for the execution environment.",
412+
]
413+
)
399414
assert sig.parameters["execution_context"].default is Parameter.empty
400415

401416
assert "user_scores" in sig.parameters
402-
assert sig.parameters["user_scores"].annotation == dict[str, int]
417+
assert (
418+
sig.parameters["user_scores"].annotation
419+
== Annotated[dict[str, int], "A map of user IDs to their scores."]
420+
)
403421
assert sig.parameters["user_scores"].default is Parameter.empty
404422

405423
assert "feature_flags" in sig.parameters
406-
assert sig.parameters["feature_flags"].annotation == Optional[dict[str, bool]]
424+
assert (
425+
sig.parameters["feature_flags"].annotation
426+
== Annotated[Optional[dict[str, bool]], "Optional feature flags."]
427+
)
407428
assert sig.parameters["feature_flags"].default is None
408429

409430
async def test_run_tool_with_map_params(self, toolbox: ToolboxClient):

0 commit comments

Comments
 (0)