Skip to content

Commit 53125cc

Browse files
Support mapping-based tool response fields
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent 9362ef7 commit 53125cc

2 files changed

Lines changed: 129 additions & 9 deletions

File tree

hyperbrowser/tools/__init__.py

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
from collections.abc import Mapping as MappingABC
23
from typing import Any, Dict, Mapping
34

45
from hyperbrowser.exceptions import HyperbrowserError
@@ -133,15 +134,30 @@ def _read_optional_tool_response_field(
133134
) -> str:
134135
if response_data is None:
135136
return ""
136-
try:
137-
field_value = getattr(response_data, field_name)
138-
except HyperbrowserError:
139-
raise
140-
except Exception as exc:
141-
raise HyperbrowserError(
142-
f"Failed to read {tool_name} response field '{field_name}'",
143-
original_error=exc,
144-
) from exc
137+
if isinstance(response_data, MappingABC):
138+
try:
139+
field_value = response_data[field_name]
140+
except KeyError:
141+
return ""
142+
except HyperbrowserError:
143+
raise
144+
except Exception as exc:
145+
raise HyperbrowserError(
146+
f"Failed to read {tool_name} response field '{field_name}'",
147+
original_error=exc,
148+
) from exc
149+
else:
150+
try:
151+
field_value = getattr(response_data, field_name)
152+
except AttributeError:
153+
return ""
154+
except HyperbrowserError:
155+
raise
156+
except Exception as exc:
157+
raise HyperbrowserError(
158+
f"Failed to read {tool_name} response field '{field_name}'",
159+
original_error=exc,
160+
) from exc
145161
if field_value is None:
146162
return ""
147163
if not isinstance(field_value, str):
@@ -152,8 +168,22 @@ def _read_optional_tool_response_field(
152168

153169

154170
def _read_crawl_page_field(page: Any, *, field_name: str, page_index: int) -> Any:
171+
if isinstance(page, MappingABC):
172+
try:
173+
return page[field_name]
174+
except KeyError:
175+
return None
176+
except HyperbrowserError:
177+
raise
178+
except Exception as exc:
179+
raise HyperbrowserError(
180+
f"Failed to read crawl tool page field '{field_name}' at index {page_index}",
181+
original_error=exc,
182+
) from exc
155183
try:
156184
return getattr(page, field_name)
185+
except AttributeError:
186+
return None
157187
except HyperbrowserError:
158188
raise
159189
except Exception as exc:

tests/test_tools_response_handling.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
from collections.abc import Mapping
23
from types import SimpleNamespace
34
from typing import Any, Optional
45

@@ -149,6 +150,45 @@ def test_scrape_tool_rejects_non_string_markdown_field():
149150
WebsiteScrapeTool.runnable(client, {"url": "https://example.com"})
150151

151152

153+
def test_scrape_tool_supports_mapping_response_data():
154+
client = _SyncScrapeClient(_Response(data={"markdown": "from mapping"}))
155+
156+
output = WebsiteScrapeTool.runnable(client, {"url": "https://example.com"})
157+
158+
assert output == "from mapping"
159+
160+
161+
def test_scrape_tool_returns_empty_for_missing_mapping_markdown_field():
162+
client = _SyncScrapeClient(_Response(data={"other": "value"}))
163+
164+
output = WebsiteScrapeTool.runnable(client, {"url": "https://example.com"})
165+
166+
assert output == ""
167+
168+
169+
def test_scrape_tool_wraps_mapping_field_read_failures():
170+
class _BrokenMapping(Mapping[str, object]):
171+
def __iter__(self):
172+
yield "markdown"
173+
174+
def __len__(self) -> int:
175+
return 1
176+
177+
def __getitem__(self, key: str) -> object:
178+
_ = key
179+
raise RuntimeError("cannot read mapping field")
180+
181+
client = _SyncScrapeClient(_Response(data=_BrokenMapping()))
182+
183+
with pytest.raises(
184+
HyperbrowserError,
185+
match="Failed to read scrape tool response field 'markdown'",
186+
) as exc_info:
187+
WebsiteScrapeTool.runnable(client, {"url": "https://example.com"})
188+
189+
assert exc_info.value.original_error is not None
190+
191+
152192
def test_screenshot_tool_rejects_non_string_screenshot_field():
153193
client = _SyncScrapeClient(_Response(data=SimpleNamespace(screenshot=123)))
154194

@@ -185,6 +225,48 @@ def markdown(self) -> str:
185225
assert exc_info.value.original_error is not None
186226

187227

228+
def test_crawl_tool_supports_mapping_page_items():
229+
client = _SyncCrawlClient(
230+
_Response(data=[{"url": "https://example.com", "markdown": "mapping body"}])
231+
)
232+
233+
output = WebsiteCrawlTool.runnable(client, {"url": "https://example.com"})
234+
235+
assert "Url: https://example.com" in output
236+
assert "mapping body" in output
237+
238+
239+
def test_crawl_tool_skips_mapping_pages_without_markdown_key():
240+
client = _SyncCrawlClient(_Response(data=[{"url": "https://example.com"}]))
241+
242+
output = WebsiteCrawlTool.runnable(client, {"url": "https://example.com"})
243+
244+
assert output == ""
245+
246+
247+
def test_crawl_tool_wraps_mapping_page_value_read_failures():
248+
class _BrokenPage(Mapping[str, object]):
249+
def __iter__(self):
250+
yield "markdown"
251+
252+
def __len__(self) -> int:
253+
return 1
254+
255+
def __getitem__(self, key: str) -> object:
256+
_ = key
257+
raise RuntimeError("cannot read page field")
258+
259+
client = _SyncCrawlClient(_Response(data=[_BrokenPage()]))
260+
261+
with pytest.raises(
262+
HyperbrowserError,
263+
match="Failed to read crawl tool page field 'markdown' at index 0",
264+
) as exc_info:
265+
WebsiteCrawlTool.runnable(client, {"url": "https://example.com"})
266+
267+
assert exc_info.value.original_error is not None
268+
269+
188270
def test_crawl_tool_rejects_non_string_page_urls():
189271
client = _SyncCrawlClient(
190272
_Response(data=[SimpleNamespace(url=42, markdown="body")])
@@ -233,6 +315,14 @@ def test_browser_use_tool_rejects_non_string_final_result():
233315
BrowserUseTool.runnable(client, {"task": "search docs"})
234316

235317

318+
def test_browser_use_tool_supports_mapping_response_data():
319+
client = _SyncBrowserUseClient(_Response(data={"final_result": "mapping output"}))
320+
321+
output = BrowserUseTool.runnable(client, {"task": "search docs"})
322+
323+
assert output == "mapping output"
324+
325+
236326
def test_async_scrape_tool_wraps_response_data_read_failures():
237327
async def run() -> None:
238328
client = _AsyncScrapeClient(

0 commit comments

Comments
 (0)