Skip to content

Commit 32e8e24

Browse files
Harden tool parameter mapping normalization
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent 4c58b77 commit 32e8e24

2 files changed

Lines changed: 118 additions & 2 deletions

File tree

hyperbrowser/tools/__init__.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,24 @@
2323
CRAWL_TOOL_ANTHROPIC,
2424
)
2525

26+
_MAX_KEY_DISPLAY_LENGTH = 120
27+
_TRUNCATED_KEY_DISPLAY_SUFFIX = "... (truncated)"
28+
29+
30+
def _format_tool_param_key_for_error(key: str) -> str:
31+
normalized_key = "".join(
32+
"?" if ord(character) < 32 or ord(character) == 127 else character
33+
for character in key
34+
).strip()
35+
if not normalized_key:
36+
return "<blank key>"
37+
if len(normalized_key) <= _MAX_KEY_DISPLAY_LENGTH:
38+
return normalized_key
39+
available_length = _MAX_KEY_DISPLAY_LENGTH - len(_TRUNCATED_KEY_DISPLAY_SUFFIX)
40+
if available_length <= 0:
41+
return _TRUNCATED_KEY_DISPLAY_SUFFIX
42+
return f"{normalized_key[:available_length]}{_TRUNCATED_KEY_DISPLAY_SUFFIX}"
43+
2644

2745
def _prepare_extract_tool_params(params: Mapping[str, Any]) -> Dict[str, Any]:
2846
normalized_params = _to_param_dict(params)
@@ -41,7 +59,32 @@ def _prepare_extract_tool_params(params: Mapping[str, Any]) -> Dict[str, Any]:
4159
def _to_param_dict(params: Mapping[str, Any]) -> Dict[str, Any]:
4260
if not isinstance(params, Mapping):
4361
raise HyperbrowserError("tool params must be a mapping")
44-
return dict(params)
62+
try:
63+
param_keys = list(params.keys())
64+
except HyperbrowserError:
65+
raise
66+
except Exception as exc:
67+
raise HyperbrowserError(
68+
"Failed to read tool params keys",
69+
original_error=exc,
70+
) from exc
71+
for key in param_keys:
72+
if isinstance(key, str):
73+
continue
74+
raise HyperbrowserError("tool params keys must be strings")
75+
normalized_params: Dict[str, Any] = {}
76+
for key in param_keys:
77+
try:
78+
normalized_params[key] = params[key]
79+
except HyperbrowserError:
80+
raise
81+
except Exception as exc:
82+
key_display = _format_tool_param_key_for_error(key)
83+
raise HyperbrowserError(
84+
f"Failed to read tool param '{key_display}'",
85+
original_error=exc,
86+
) from exc
87+
return normalized_params
4588

4689

4790
class WebsiteScrapeTool:

tests/test_tools_mapping_inputs.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import asyncio
2+
from collections.abc import Iterator, Mapping
13
from types import MappingProxyType
24

3-
import asyncio
45
import pytest
56

67
from hyperbrowser.exceptions import HyperbrowserError
@@ -95,3 +96,75 @@ async def run() -> None:
9596
)
9697

9798
asyncio.run(run())
99+
100+
101+
def test_tool_wrappers_reject_non_string_param_keys():
102+
client = _Client()
103+
104+
with pytest.raises(HyperbrowserError, match="tool params keys must be strings"):
105+
WebsiteScrapeTool.runnable(
106+
client,
107+
{1: "https://example.com"}, # type: ignore[dict-item]
108+
)
109+
110+
111+
def test_tool_wrappers_wrap_param_key_read_failures():
112+
class _BrokenKeyMapping(Mapping[str, object]):
113+
def __iter__(self) -> Iterator[str]:
114+
raise RuntimeError("cannot iterate keys")
115+
116+
def __len__(self) -> int:
117+
return 1
118+
119+
def __getitem__(self, key: str) -> object:
120+
_ = key
121+
return "ignored"
122+
123+
client = _Client()
124+
125+
with pytest.raises(HyperbrowserError, match="Failed to read tool params keys") as exc_info:
126+
WebsiteScrapeTool.runnable(client, _BrokenKeyMapping())
127+
128+
assert exc_info.value.original_error is not None
129+
130+
131+
def test_tool_wrappers_wrap_param_value_read_failures():
132+
class _BrokenValueMapping(Mapping[str, object]):
133+
def __iter__(self) -> Iterator[str]:
134+
yield "url"
135+
136+
def __len__(self) -> int:
137+
return 1
138+
139+
def __getitem__(self, key: str) -> object:
140+
_ = key
141+
raise RuntimeError("cannot read value")
142+
143+
client = _Client()
144+
145+
with pytest.raises(HyperbrowserError, match="Failed to read tool param 'url'") as exc_info:
146+
WebsiteScrapeTool.runnable(client, _BrokenValueMapping())
147+
148+
assert exc_info.value.original_error is not None
149+
150+
151+
def test_tool_wrappers_preserve_hyperbrowser_param_value_read_failures():
152+
class _BrokenValueMapping(Mapping[str, object]):
153+
def __iter__(self) -> Iterator[str]:
154+
yield "url"
155+
156+
def __len__(self) -> int:
157+
return 1
158+
159+
def __getitem__(self, key: str) -> object:
160+
_ = key
161+
raise HyperbrowserError("custom param value read failure")
162+
163+
client = _Client()
164+
165+
with pytest.raises(
166+
HyperbrowserError, match="custom param value read failure"
167+
) as exc_info:
168+
WebsiteScrapeTool.runnable(client, _BrokenValueMapping())
169+
170+
assert exc_info.value.original_error is None

0 commit comments

Comments
 (0)