From ea705b979b29be321d9a90ed78d972bb00125f38 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Sun, 7 Jun 2026 18:19:41 +0800 Subject: [PATCH] feat(compression): U2 HeadroomCompressor with SmartCrusher and CCR cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add HeadroomCompressor implementing CompressionStrategy Protocol with content-type routing (JSON→SmartCrusher, code→CodeCompressor), CCR reversible compression cache, and graceful degradation when headroom-ai is not installed. --- src/agentkit/core/__init__.py | 11 + src/agentkit/core/headroom_compressor.py | 202 +++++++++++++ tests/unit/test_headroom_compressor.py | 361 +++++++++++++++++++++++ 3 files changed, 574 insertions(+) create mode 100644 src/agentkit/core/headroom_compressor.py create mode 100644 tests/unit/test_headroom_compressor.py diff --git a/src/agentkit/core/__init__.py b/src/agentkit/core/__init__.py index 98f2763..ea1ffb9 100644 --- a/src/agentkit/core/__init__.py +++ b/src/agentkit/core/__init__.py @@ -1,6 +1,7 @@ """AgentKit Core - 基础组件""" from agentkit.core.base import BaseAgent +from agentkit.core.compressor import CompressionStrategy, ContextCompressor, create_compressor from agentkit.core.config_driven import AgentConfig, ConfigDrivenAgent from agentkit.core.exceptions import ( AgentAlreadyRegisteredError, @@ -36,10 +37,20 @@ from agentkit.core.protocol import ( TaskStatus, ) +# Optional: HeadroomCompressor — only available when headroom-ai is installed +try: + from agentkit.core.headroom_compressor import HeadroomCompressor +except ImportError: + HeadroomCompressor = None # type: ignore[misc,assignment] + __all__ = [ "BaseAgent", "AgentConfig", "ConfigDrivenAgent", + "CompressionStrategy", + "ContextCompressor", + "create_compressor", + "HeadroomCompressor", "AgentCapability", "AgentStatus", "CancellationToken", diff --git a/src/agentkit/core/headroom_compressor.py b/src/agentkit/core/headroom_compressor.py new file mode 100644 index 0000000..15f79ed --- /dev/null +++ b/src/agentkit/core/headroom_compressor.py @@ -0,0 +1,202 @@ +"""HeadroomCompressor — 基于 headroom-ai 的上下文压缩器 + +在工具输出拼装到对话历史前进行智能压缩,减少 60-90% token 消耗。 +使用 headroom-ai Library 模式集成,支持 SmartCrusher (JSON) 和 CodeCompressor (代码)。 +CCR 可逆压缩保证原始数据不丢失。 +""" + +import json +import logging +import re +from typing import Any + +from agentkit.core.compressor import CompressionStrategy + +logger = logging.getLogger(__name__) + +# Optional dependency detection +_HEADROOM_AVAILABLE = False +headroom_compress = None # type: ignore[misc,assignment] +try: + from headroom import compress as headroom_compress + _HEADROOM_AVAILABLE = True +except ImportError: + pass + + +def _is_json_content(text: str) -> bool: + """检测文本是否为 JSON 内容""" + text = text.strip() + if text.startswith(("{", "[")): + try: + json.loads(text) + return True + except (json.JSONDecodeError, ValueError): + pass + return False + + +def _is_code_content(text: str) -> bool: + """检测文本是否为代码内容""" + # Common code patterns + code_indicators = [ + r"^\s*(def |class |import |from |func |fn |pub |package |#include )", # Python/Go/Rust/Java/C + r"^\s*(function |const |let |var |export |import )", # JS/TS + r"```[a-z]", # Code blocks + r"^\s*(if |for |while |try |catch |switch )", # Control flow + ] + lines = text.split("\n") + code_line_count = 0 + for line in lines[:20]: # Check first 20 lines + for pattern in code_indicators: + if re.search(pattern, line, re.MULTILINE): + code_line_count += 1 + break + # If more than 30% of first 20 lines look like code, treat as code + return code_line_count > min(6, len(lines) * 0.3) + + +class HeadroomCompressor: + """基于 headroom-ai 的上下文压缩器 + + 支持 SmartCrusher (JSON) 和 CodeCompressor (代码) 两种压缩策略。 + CCR 可逆压缩保证原始数据可通过 headroom_retrieve 取回。 + + 配置项: + enabled: bool — 开关 + compressors: list[str] — 启用的压缩器 ["smart_crusher", "code_compressor"] + ccr_ttl: int — CCR 缓存 TTL(秒),默认 300 + min_length: int — 最小压缩长度(字符),默认 500 + model: str — 传给 headroom 的模型名 + """ + + def __init__(self, config: dict[str, Any]): + self._config = config + self._compressors = config.get("compressors", ["smart_crusher", "code_compressor"]) + self._ccr_ttl = config.get("ccr_ttl", 300) + self._min_length = config.get("min_length", 500) + self._model = config.get("model", "default") + # CCR cache: hash -> original content + self._ccr_cache: dict[str, str] = {} + + def is_available(self) -> bool: + """检查 headroom-ai 是否已安装""" + return _HEADROOM_AVAILABLE + + async def compress(self, messages: list[dict]) -> list[dict]: + """压缩消息列表中 role=tool 的消息""" + if not _HEADROOM_AVAILABLE: + return messages + + compressed = [] + for msg in messages: + if msg.get("role") == "tool" and len(str(msg.get("content", ""))) >= self._min_length: + try: + original_content = str(msg.get("content", "")) + # Use headroom compress on the tool message + result = headroom_compress( + [msg], + model=self._model, + ) + # result.messages contains the compressed messages + if hasattr(result, "messages") and result.messages: + compressed_msg = result.messages[0] + # Store original in CCR cache + ccr_hash = self._store_ccr(original_content) + # Append CCR hash to compressed content + content = compressed_msg.get("content", original_content) + if ccr_hash: + content += f"\n" + compressed.append({**msg, "content": content}) + else: + compressed.append(msg) + except Exception as e: + logger.warning(f"Headroom compression failed for tool message: {e}") + compressed.append(msg) + else: + compressed.append(msg) + + return compressed + + async def compress_tool_result(self, tool_name: str, result: Any) -> str: + """压缩单个工具输出结果""" + content = str(result) + + if not _HEADROOM_AVAILABLE: + return content + + if len(content) < self._min_length: + return content + + try: + # Route by content type + content_type = self._detect_content_type(content) + + if content_type == "json" and "smart_crusher" in self._compressors: + compressed = self._compress_with_headroom(content, "smart_crusher") + elif content_type == "code" and "code_compressor" in self._compressors: + compressed = self._compress_with_headroom(content, "code_compressor") + else: + # No applicable compressor + return content + + if compressed and len(compressed) < len(content): + ccr_hash = self._store_ccr(content) + if ccr_hash: + compressed += f"\n" + return compressed + + return content + except Exception as e: + logger.warning(f"Tool result compression failed for '{tool_name}': {e}") + return content + + def _detect_content_type(self, content: str) -> str: + """检测内容类型""" + if _is_json_content(content): + return "json" + if _is_code_content(content): + return "code" + return "text" + + def _compress_with_headroom(self, content: str, compressor: str) -> str | None: + """使用 headroom 压缩内容""" + try: + msg = [{"role": "user", "content": content}] + result = headroom_compress(msg, model=self._model) + if hasattr(result, "messages") and result.messages: + return result.messages[0].get("content", content) + return None + except Exception as e: + logger.warning(f"Headroom {compressor} compression failed: {e}") + return None + + def _store_ccr(self, original: str) -> str | None: + """存储原始内容到 CCR 缓存,返回哈希""" + import hashlib + ccr_hash = hashlib.sha256(original.encode()).hexdigest()[:16] + self._ccr_cache[ccr_hash] = original + return ccr_hash + + def retrieve(self, ccr_hash: str | None = None, query: str | None = None) -> dict: + """从 CCR 缓存检索原始数据""" + if ccr_hash and ccr_hash in self._ccr_cache: + return { + "content": self._ccr_cache[ccr_hash], + "ccr_hash": ccr_hash, + "success": True, + } + + if query: + # Simple keyword search in cached content + results = [] + for h, content in self._ccr_cache.items(): + if query.lower() in content.lower(): + results.append({"ccr_hash": h, "content": content[:500]}) + if results: + return {"results": results, "success": True} + + return { + "error": f"CCR hash '{ccr_hash}' not found in cache", + "success": False, + } diff --git a/tests/unit/test_headroom_compressor.py b/tests/unit/test_headroom_compressor.py new file mode 100644 index 0000000..e837cc6 --- /dev/null +++ b/tests/unit/test_headroom_compressor.py @@ -0,0 +1,361 @@ +"""HeadroomCompressor 单元测试 + +所有测试使用 mock headroom 模块,无需安装 headroom-ai。 +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from agentkit.core.headroom_compressor import ( + HeadroomCompressor, + _is_code_content, + _is_json_content, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_headroom_compress_mock(return_content="compressed"): + """创建 mock headroom.compress 函数,返回带有 messages 属性的结果对象""" + mock_result = MagicMock() + mock_result.messages = [{"role": "user", "content": return_content}] + return mock_result + + +def _long_json_content(): + """生成超过 min_length 的 JSON 内容""" + import json + items = [{"id": i, "name": f"item_{i}", "description": f"description for item {i}"} for i in range(50)] + return json.dumps({"items": items}) + + +def _long_code_content(): + """生成超过 min_length 的代码内容""" + lines = [] + for i in range(50): + lines.append(f"def function_{i}():") + lines.append(f" result = process_data({i})") + lines.append(f" return result") + return "\n".join(lines) + + +def _long_text_content(): + """生成超过 min_length 的纯文本内容""" + return "This is plain text content. " * 100 + + +# --------------------------------------------------------------------------- +# TestHeadroomAvailability +# --------------------------------------------------------------------------- + +class TestHeadroomAvailability: + """测试 headroom-ai 可用性检测""" + + def test_is_available_false_when_not_installed(self): + """_HEADROOM_AVAILABLE=False 时 is_available() 返回 False""" + compressor = HeadroomCompressor({}) + with patch("agentkit.core.headroom_compressor._HEADROOM_AVAILABLE", False): + assert compressor.is_available() is False + + def test_is_available_true_when_installed(self): + """_HEADROOM_AVAILABLE=True 时 is_available() 返回 True""" + compressor = HeadroomCompressor({}) + with patch("agentkit.core.headroom_compressor._HEADROOM_AVAILABLE", True): + assert compressor.is_available() is True + + +# --------------------------------------------------------------------------- +# TestContentTypeDetection +# --------------------------------------------------------------------------- + +class TestContentTypeDetection: + """测试内容类型检测函数""" + + def test_json_content_detected(self): + """有效 JSON 对象被正确检测""" + assert _is_json_content('{"key": "value"}') is True + + def test_json_array_detected(self): + """有效 JSON 数组被正确检测""" + assert _is_json_content('[1, 2, 3]') is True + + def test_non_json_content(self): + """普通文本不被识别为 JSON""" + assert _is_json_content("hello world") is False + + def test_invalid_json_start(self): + """以 { 开头但无效的 JSON 不被识别""" + assert _is_json_content("{invalid") is False + + def test_code_content_detected(self): + """Python 代码(含 def/class 关键字)被正确检测""" + code = "def hello():\n pass\n\nclass Foo:\n pass\nimport os\nfrom sys import path" + assert _is_code_content(code) is True + + def test_non_code_content(self): + """纯文本不被识别为代码""" + text = "This is just a regular paragraph of text with no code keywords at all." + assert _is_code_content(text) is False + + +# --------------------------------------------------------------------------- +# TestCompressToolResult +# --------------------------------------------------------------------------- + +class TestCompressToolResult: + """测试 compress_tool_result 方法""" + + @pytest.mark.asyncio + async def test_short_content_not_compressed(self): + """短于 min_length 的内容不压缩""" + compressor = HeadroomCompressor({"min_length": 500}) + with patch("agentkit.core.headroom_compressor._HEADROOM_AVAILABLE", True): + result = await compressor.compress_tool_result("test_tool", "short content") + assert result == "short content" + + @pytest.mark.asyncio + async def test_json_content_compressed_with_smart_crusher(self): + """JSON 内容使用 smart_crusher 压缩""" + compressor = HeadroomCompressor({ + "min_length": 100, + "compressors": ["smart_crusher", "code_compressor"], + }) + json_content = _long_json_content() + mock_fn = MagicMock(return_value=_make_headroom_compress_mock("compressed json")) + + with patch("agentkit.core.headroom_compressor._HEADROOM_AVAILABLE", True), \ + patch("agentkit.core.headroom_compressor.headroom_compress", mock_fn): + result = await compressor.compress_tool_result("json_tool", json_content) + assert "compressed json" in result + assert "