From 779c0dd80d47a9f552990f5789749db16f7a7d63 Mon Sep 17 00:00:00 2001 From: Frowtek Date: Sat, 11 Jul 2026 12:22:56 +0300 Subject: [PATCH] fix(compressor): unwrap web_extract dict URLs in tool-result summaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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. --- agent/context_compressor.py | 11 ++++++- tests/agent/test_context_compressor.py | 45 ++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index fb998a1dbe3d..5ff794acd1e5 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -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)" diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 230090fd6f95..9a120ba94892 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -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