fix(tools): handle dict URLs in web_extract display and tool processing

When web_search results are passed directly to web_extract, the URLs
field contains dict objects (e.g., {"url": "...", "title": "..."})
rather than plain URL strings. Two code paths assumed URLs were always
strings and crashed:

- agent/display.py get_cute_tool_message for web_extract: tried to call
  url.replace() on a dict, causing AttributeError
- tools/web_tools.py web_extract_tool loop: tried regex search on a dict,
  causing TypeError

Both now extract the URL string from dict objects (url or href field) or
fall back to empty string, preserving the cosmetic display and allowing
the tool to process the URLs correctly.

Fixes #61693
This commit is contained in:
liuhao1024 2026-07-10 07:17:54 +08:00 committed by kshitij
parent 8727e67295
commit 7ae9faecf7
4 changed files with 199 additions and 0 deletions

View file

@ -1302,6 +1302,11 @@ def get_cute_tool_message(
urls = args.get("urls", [])
if urls:
url = urls[0] if isinstance(urls, list) else str(urls)
# Handle dict objects from web_search results
if isinstance(url, dict):
url = url.get("url") or url.get("href") or ""
if not isinstance(url, str):
url = str(url)
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
extra = f" +{len(urls)-1}" if len(urls) > 1 else ""
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")

View file

@ -240,3 +240,78 @@ class TestTodoSkinIntegration:
def test_default_skin_prefix(self):
msg = get_cute_tool_message("todo", {}, 0.5)
assert msg.startswith("")
class TestWebExtractDisplay:
"""get_cute_tool_message for web_extract handles dict objects from web_search results.
Reproduces and verifies fix for #61693 where web_search result dicts
caused AttributeError when web_extract tried to extract domain names.
"""
def test_web_extract_with_dict_url_field(self):
"""Dict with 'url' field (standard web_search result shape)."""
args = {
"urls": [
{"url": "https://example.com/path", "title": "Example", "snippet": "..."}
]
}
msg = get_cute_tool_message("web_extract", args, 0.5)
assert "example.com" in msg
assert "📄" in msg
assert "0.5s" in msg
assert "+" not in msg # only 1 URL
def test_web_extract_with_dict_href_field(self):
"""Dict with 'href' field (alternate key)."""
args = {
"urls": [
{"href": "http://test.org/page", "title": "Test", "snippet": "..."}
]
}
msg = get_cute_tool_message("web_extract", args, 0.3)
assert "test.org" in msg
def test_web_extract_with_dict_no_url_field(self):
"""Dict without url/href fields - should not crash."""
args = {
"urls": [
{"title": "No URL", "snippet": "Missing url field"}
]
}
msg = get_cute_tool_message("web_extract", args, 0.2)
# Should handle gracefully, default to empty string domain
assert "📄" in msg
assert "0.2s" in msg
def test_web_extract_with_multiple_dicts(self):
"""Multiple dict URLs show '+N' suffix."""
args = {
"urls": [
{"url": "https://example.com/page1"},
{"url": "https://example.com/page2"},
{"url": "https://example.com/page3"},
]
}
msg = get_cute_tool_message("web_extract", args, 0.6)
assert "example.com" in msg
assert "+2" in msg # 3 URLs total, +2 beyond first
def test_web_extract_with_mixed_types(self):
"""Mix of string URLs and dict objects."""
args = {
"urls": [
"https://direct.com/page",
{"url": "https://dict.com/page", "title": "Dict URL"},
]
}
msg = get_cute_tool_message("web_extract", args, 0.4)
# First item is a string, so domain should come from it
assert "direct.com" in msg
def test_web_extract_empty_urls(self):
"""Empty urls list - shows 'pages' placeholder."""
args = {"urls": []}
msg = get_cute_tool_message("web_extract", args, 0.1)
assert "pages" in msg
assert "📄" in msg

View file

@ -0,0 +1,114 @@
"""Test web_extract_tool handles dict objects from web_search results.
Reproduces and verifies fix for #61693 where web_search result dicts
caused TypeError when web_extract tried to normalize and validate URLs.
"""
import json
from typing import List
def test_web_extract_handles_dict_urls():
"""web_extract_tool should extract URL strings from dict objects.
When the model passes web_search result dicts (with url/href fields),
web_extract_tool should extract the actual URL strings before processing.
This test verifies the dict-to-string coercion logic works without errors.
This is a minimal smoke test - it doesn't actually call web_extract_tool
(which requires external services), but verifies the core logic that
dict URLs are converted to strings before regex operations.
"""
# Simulate web_search result dicts
dict_urls: List[dict] = [
{"url": "https://example.com/page1", "title": "Example 1", "snippet": "..."},
{"url": "https://example.com/page2", "title": "Example 2", "snippet": "..."},
{"href": "https://alternate.com/page", "title": "Alternate", "snippet": "..."},
{"title": "No URL field"}, # Missing url/href
]
# This is the coercion logic from the fix in web_tools.py
processed_urls: List[str] = []
for _url in dict_urls:
# Handle dict objects from web_search results
if isinstance(_url, dict):
_url = _url.get("url") or _url.get("href") or ""
elif not isinstance(_url, str):
_url = str(_url)
processed_urls.append(_url)
# Verify extraction works
assert processed_urls == [
"https://example.com/page1",
"https://example.com/page2",
"https://alternate.com/page",
"", # Missing url/href → empty string
]
def test_web_extract_handles_mixed_string_dict_urls():
"""web_extract_tool should handle mix of string URLs and dict objects."""
mixed_urls = [
"https://direct.com/page",
{"url": "https://dict.com/page", "title": "Dict URL"},
{"href": "https://href.com/page"},
"https://another.com/page",
{"title": "No URL field"},
]
processed_urls: List[str] = []
for _url in mixed_urls:
if isinstance(_url, dict):
_url = _url.get("url") or _url.get("href") or ""
elif not isinstance(_url, str):
_url = str(_url)
processed_urls.append(_url)
assert processed_urls == [
"https://direct.com/page",
"https://dict.com/page",
"https://href.com/page",
"https://another.com/page",
"",
]
def test_web_extract_dict_coercion_preserves_valid_urls():
"""Dict coercion should not break valid URL strings."""
valid_url = "https://example.com/path?query=value#fragment"
# String URL should pass through unchanged
if isinstance(valid_url, dict):
processed = valid_url.get("url") or valid_url.get("href") or ""
elif not isinstance(valid_url, str):
processed = str(valid_url)
else:
processed = valid_url
assert processed == valid_url
def test_web_extract_dict_coercion_handles_edge_cases():
"""Test edge cases: non-dict, non-string objects, empty strings."""
edge_cases = [
123, # int
None, # None
True, # bool
"", # empty string
]
processed: List[str] = []
for item in edge_cases:
if isinstance(item, dict):
processed_item = item.get("url") or item.get("href") or ""
elif not isinstance(item, str):
processed_item = str(item)
else:
processed_item = item
processed.append(processed_item)
# Non-dict, non-string → str() conversion
assert processed[0] == "123"
assert processed[1] == "None"
assert processed[2] == "True"
# Empty string stays empty
assert processed[3] == ""

View file

@ -763,6 +763,11 @@ async def web_extract_tool(
from urllib.parse import unquote
normalized_urls: List[str] = []
for _url in urls:
# Handle dict objects from web_search results
if isinstance(_url, dict):
_url = _url.get("url") or _url.get("href") or ""
elif not isinstance(_url, str):
_url = str(_url)
normalized_url = normalize_url_for_request(_url)
if (
_PREFIX_RE.search(_url)