mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
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:
parent
8727e67295
commit
7ae9faecf7
4 changed files with 199 additions and 0 deletions
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
114
tests/tools/test_web_tools_dict_urls.py
Normal file
114
tests/tools/test_web_tools_dict_urls.py
Normal 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] == ""
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue