mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
fix(compressor): unwrap web_extract dict URLs in tool-result summaries
_summarize_tool_result() built the web_extract summary straight from the
first `urls` entry. When web_search results are forwarded into web_extract
(a common chain), that entry is a dict ({"url"/"href": ...}) rather than a
URL string. With 2+ URLs the `url_desc += " (+N more)"` step then raised
`TypeError: unsupported operand type(s) for +=: 'dict' and 'str'`, aborting
the pre-compression pruning pass; with a single URL the raw dict repr
leaked into the summary text.
Unwrap the URL from dict entries before use — mirroring the existing
web_extract handling in agent/display.py (`_display_url`),
tools/web_tools.py (`_web_extract_url`) and acp_adapter/tools.py
(`build_tool_title`) — so `url_desc` is always a string and the
concatenation is always str + str.
Adds regression tests covering multiple/single dict URLs, the href key,
malformed dicts, and the plain-string path.
This commit is contained in:
parent
8fa8aabbbb
commit
779c0dd80d
2 changed files with 55 additions and 1 deletions
|
|
@ -686,7 +686,16 @@ def _summarize_tool_result_unguarded(tool_name: str, tool_args: str, tool_conten
|
|||
|
||||
if tool_name == "web_extract":
|
||||
urls = args.get("urls", [])
|
||||
url_desc = urls[0] if isinstance(urls, list) and urls else "?"
|
||||
first = urls[0] if isinstance(urls, list) and urls else "?"
|
||||
# web_search results are dicts ({"url"/"href": ...}) and models often
|
||||
# forward them straight into web_extract. Unwrap to the URL string so
|
||||
# the summary stays readable and the ``+=`` below never hits the
|
||||
# ``dict + str`` TypeError that would abort pre-compression pruning.
|
||||
if isinstance(first, dict):
|
||||
first = first.get("url") or first.get("href") or "?"
|
||||
elif not isinstance(first, str):
|
||||
first = "?"
|
||||
url_desc = first
|
||||
if isinstance(urls, list) and len(urls) > 1:
|
||||
url_desc += f" (+{len(urls) - 1} more)"
|
||||
return f"[web_extract] {url_desc} ({content_len:,} chars)"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Tests for agent/context_compressor.py — compression logic, thresholds, truncation fallback."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
import time
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
|
@ -9,6 +10,7 @@ from agent.context_compressor import (
|
|||
HISTORICAL_TASK_HEADING,
|
||||
SUMMARY_PREFIX,
|
||||
COMPRESSED_SUMMARY_METADATA_KEY,
|
||||
_summarize_tool_result,
|
||||
)
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
|
@ -27,6 +29,49 @@ def compressor():
|
|||
return c
|
||||
|
||||
|
||||
class TestSummarizeToolResultWebExtract:
|
||||
"""Pre-compression pruning must survive web_extract calls whose ``urls`` are
|
||||
web_search result dicts ({"url"/"href": ...}), which models routinely forward
|
||||
straight into web_extract.
|
||||
"""
|
||||
|
||||
CONTENT = "x" * 500 # >200 chars so the pruning pass actually summarizes
|
||||
|
||||
def test_multiple_dict_urls_do_not_crash(self):
|
||||
# Two dict URLs previously hit ``dict + str`` -> TypeError, aborting
|
||||
# _prune_old_tool_results() (and thus compress()).
|
||||
args = json.dumps({
|
||||
"urls": [
|
||||
{"url": "https://example.com/a", "title": "A"},
|
||||
{"url": "https://example.org/b", "title": "B"},
|
||||
]
|
||||
})
|
||||
summary = _summarize_tool_result("web_extract", args, self.CONTENT)
|
||||
assert summary == "[web_extract] https://example.com/a (+1 more) (500 chars)"
|
||||
|
||||
def test_single_dict_url_is_unwrapped_not_stringified(self):
|
||||
args = json.dumps({"urls": [{"url": "https://example.com/a", "title": "A"}]})
|
||||
summary = _summarize_tool_result("web_extract", args, self.CONTENT)
|
||||
assert summary == "[web_extract] https://example.com/a (500 chars)"
|
||||
assert "{" not in summary # no raw dict repr leaked into the summary
|
||||
|
||||
def test_href_key_is_unwrapped(self):
|
||||
args = json.dumps({"urls": [{"href": "https://example.com/h"}]})
|
||||
summary = _summarize_tool_result("web_extract", args, self.CONTENT)
|
||||
assert summary == "[web_extract] https://example.com/h (500 chars)"
|
||||
|
||||
def test_malformed_dict_falls_back_to_placeholder(self):
|
||||
args = json.dumps({"urls": [{"title": "no url here"}, {"title": "still none"}]})
|
||||
summary = _summarize_tool_result("web_extract", args, self.CONTENT)
|
||||
assert summary == "[web_extract] ? (+1 more) (500 chars)"
|
||||
|
||||
def test_plain_string_urls_unchanged(self):
|
||||
# Regression guard: the normal (already-working) string path is intact.
|
||||
args = json.dumps({"urls": ["https://example.com/a", "https://example.org/b"]})
|
||||
summary = _summarize_tool_result("web_extract", args, self.CONTENT)
|
||||
assert summary == "[web_extract] https://example.com/a (+1 more) (500 chars)"
|
||||
|
||||
|
||||
class TestShouldCompress:
|
||||
def test_below_threshold(self, compressor):
|
||||
compressor.last_prompt_tokens = 50000
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue