fix(image-search): keep the source page URL in DuckDuckGo and Serper results (#6915)

* test(image-search): expect source_url in DuckDuckGo and Serper results

* fix(image-search): keep the source page URL in DuckDuckGo and Serper results

The DuckDuckGo and Serper image_search tools returned only title,
image_url and thumbnail_url and dropped the page each image comes from
(DDGS `url`, Serper `link`). Brave already returns it as `source_url`,
and the workspace UI links every image thumbnail through `source_url`,
so DuckDuckGo (the default provider) and Serper results rendered as
"Unsafe link omitted" and the agent lost the image's source page.

Return `source_url` from both providers, screened with
`safe_public_result_url` like the image and thumbnail URLs.
This commit is contained in:
Chaoqi 2026-10-15 02:56:46 -04:00 committed by GitHub
parent 2dc92a2e4a
commit d06fc14be9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 23 additions and 14 deletions

View File

@ -1,12 +1,12 @@
# Image Search
DDG, Brave, and Serper `image_search` screen returned image and thumbnail URLs with
`community.url_safety.safe_public_result_url`. This pure string check does not
resolve hostnames; any later downloader must enforce its own connection-time
address policy. The screen reuses `is_blocked_address` for literal and embedded
IPv4 addresses, including multicast/reserved forms. Shared regression coverage
is in `tests/test_image_result_url_safety.py`; DDG tool coverage is in
`tests/test_ddg_image_search_urls.py`.
DDG, Brave, and Serper `image_search` screen returned image, thumbnail and
source page URLs with `community.url_safety.safe_public_result_url`. This pure
string check does not resolve hostnames; any later downloader must enforce its
own connection-time address policy. The screen reuses `is_blocked_address` for
literal and embedded IPv4 addresses, including multicast/reserved forms. Shared
regression coverage is in `tests/test_image_result_url_safety.py`; DDG tool
coverage is in `tests/test_ddg_image_search_urls.py`.
Normalize IDNA's Unicode dot separators before checking blocked hostnames or IP
literals; preserve the original URL for allowed results. Client-normalization

View File

@ -147,6 +147,7 @@ async def image_search_tool(
"title": title if isinstance(title, str) else "",
"image_url": image_url,
"thumbnail_url": thumbnail_url,
"source_url": safe_public_result_url(row.get("url")),
}
)

View File

@ -497,6 +497,7 @@ def image_search_tool(query: str, max_results: int = 5) -> str:
"title": r.get("title", ""),
"image_url": image_url,
"thumbnail_url": thumbnail_url,
"source_url": _safe_public_url(r.get("link")),
}
)
if len(normalized_results) >= max_results:

View File

@ -10,8 +10,8 @@ from deerflow.community.url_safety import safe_public_result_url
@pytest.mark.asyncio
async def test_image_search_drops_unsafe_result_urls() -> None:
rows = [
{"title": "safe", "image": "https://images.example/full.jpg", "thumbnail": "https://images.example/thumb.jpg"},
{"title": "unsafe original", "image": "http://127.0.0.1/private", "thumbnail": "https://images.example/thumb2.jpg"},
{"title": "safe", "image": "https://images.example/full.jpg", "thumbnail": "https://images.example/thumb.jpg", "url": "https://images.example/page"},
{"title": "unsafe original", "image": "http://127.0.0.1/private", "thumbnail": "https://images.example/thumb2.jpg", "url": "http://10.0.0.1/page"},
{"title": "unsafe thumbnail", "image": "https://images.example/full3.jpg", "thumbnail": "javascript:alert(1)"},
{"title": "both unsafe", "image": "http://2130706433/private", "thumbnail": "http://localhost/thumb"},
]
@ -21,9 +21,9 @@ async def test_image_search_drops_unsafe_result_urls() -> None:
assert result["total_results"] == 3
assert result["results"] == [
{"title": "safe", "image_url": "https://images.example/full.jpg", "thumbnail_url": "https://images.example/thumb.jpg"},
{"title": "unsafe original", "image_url": "", "thumbnail_url": "https://images.example/thumb2.jpg"},
{"title": "unsafe thumbnail", "image_url": "https://images.example/full3.jpg", "thumbnail_url": ""},
{"title": "safe", "image_url": "https://images.example/full.jpg", "thumbnail_url": "https://images.example/thumb.jpg", "source_url": "https://images.example/page"},
{"title": "unsafe original", "image_url": "", "thumbnail_url": "https://images.example/thumb2.jpg", "source_url": ""},
{"title": "unsafe thumbnail", "image_url": "https://images.example/full3.jpg", "thumbnail_url": "", "source_url": ""},
]

View File

@ -49,4 +49,4 @@ async def test_ddg_image_search_keeps_safe_thumbnail_when_idna_image_is_private(
result = json.loads(await tools.image_search_tool.ainvoke({"query": "reference"}))
assert result["total_results"] == 1
assert result["results"] == [{"title": "reference", "image_url": "", "thumbnail_url": "https://images.example/thumb.jpg"}]
assert result["results"] == [{"title": "reference", "image_url": "", "thumbnail_url": "https://images.example/thumb.jpg", "source_url": ""}]

View File

@ -18,6 +18,7 @@ async def test_image_search_uses_full_image_url_not_thumbnail():
"title": "a cat",
"image": "https://example.com/full.jpg",
"thumbnail": "https://example.com/thumb.jpg",
"url": "https://example.com/cats/page",
}
]
cfg = MagicMock()
@ -32,6 +33,8 @@ async def test_image_search_uses_full_image_url_not_thumbnail():
result = output["results"][0]
assert result["image_url"] == "https://example.com/full.jpg"
assert result["thumbnail_url"] == "https://example.com/thumb.jpg"
# The web UI links each thumbnail through `source_url`, as Brave's image_search emits it.
assert result["source_url"] == "https://example.com/cats/page"
async def _search_kwargs(query="a cat", **kwargs):

View File

@ -846,11 +846,13 @@ class TestImageSearchTool:
"title": "Cat 1",
"imageUrl": "https://example.com/cat1.jpg",
"thumbnailUrl": "https://example.com/cat1_thumb.jpg",
"link": "https://example.com/cats/1",
},
{
"title": "Cat 2",
"imageUrl": "https://example.com/cat2.jpg",
"thumbnailUrl": "https://example.com/cat2_thumb.jpg",
"link": "https://example.com/cats/2",
},
]
mock_resp = _make_serper_images_response(images)
@ -868,6 +870,7 @@ class TestImageSearchTool:
assert parsed["results"][0]["title"] == "Cat 1"
assert parsed["results"][0]["image_url"] == "https://example.com/cat1.jpg"
assert parsed["results"][0]["thumbnail_url"] == "https://example.com/cat1_thumb.jpg"
assert parsed["results"][0]["source_url"] == "https://example.com/cats/1"
assert parsed["usage_hint"] == "Use the 'image_url' values as reference images in image generation. Download them first if needed."
def test_sends_correct_headers_and_payload_to_images_endpoint(self, mock_config_with_key):
@ -1277,7 +1280,7 @@ class TestImageSearchTool:
images = [
{"title": "Local", "imageUrl": "file:///etc/passwd", "thumbnailUrl": "http://127.0.0.1/thumb.jpg"},
{"title": "Data", "imageUrl": "data:image/png;base64,abc", "thumbnailUrl": "http://10.0.0.1/thumb.jpg"},
{"title": "Safe", "imageUrl": "https://example.com/i.jpg", "thumbnailUrl": "http://example.com/t.jpg"},
{"title": "Safe", "imageUrl": "https://example.com/i.jpg", "thumbnailUrl": "http://example.com/t.jpg", "link": "http://127.0.0.1/page"},
]
mock_resp = _make_serper_images_response(images)
@ -1293,6 +1296,7 @@ class TestImageSearchTool:
assert parsed["results"][0]["title"] == "Safe"
assert parsed["results"][0]["image_url"] == "https://example.com/i.jpg"
assert parsed["results"][0]["thumbnail_url"] == "http://example.com/t.jpg"
assert parsed["results"][0]["source_url"] == ""
def test_all_unsafe_image_urls_return_error(self, mock_config_with_key):
images = [