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:
Frowtek 2026-07-11 12:22:56 +03:00 committed by Teknium
parent 8fa8aabbbb
commit 779c0dd80d
2 changed files with 55 additions and 1 deletions

View file

@ -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)"

View file

@ -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