From d09e9fe14ee4108fb20af6308b04b6dd8a928b88 Mon Sep 17 00:00:00 2001 From: Matt Owen Date: Wed, 5 Aug 2026 16:50:56 -0400 Subject: [PATCH] Add context summarization to hunter loop, preserve findings Wire ContextSummarizer into NativeHunter's step loop. All tool calls and tool results are kept verbatim through summarization; only plain assistant/user prose gets compressed by the LLM. --- clearwing/data/memory/summarizer.py | 61 +++++++++++++---------------- clearwing/sourcehunt/hunter.py | 10 ++++- 2 files changed, 37 insertions(+), 34 deletions(-) diff --git a/clearwing/data/memory/summarizer.py b/clearwing/data/memory/summarizer.py index e64d36bf..8f80b302 100644 --- a/clearwing/data/memory/summarizer.py +++ b/clearwing/data/memory/summarizer.py @@ -2,10 +2,14 @@ from __future__ import annotations +import logging import re from typing import Any -# Patterns that indicate a captured flag — these messages must never be dropped. +from clearwing.llm import ChatMessage + +logger = logging.getLogger(__name__) + _FLAG_PATTERNS = re.compile( r"(flag\{[^}]*\}|FLAG\{[^}]*\}|HTB\{[^}]*\}|CTF\{[^}]*\})", re.IGNORECASE ) @@ -17,63 +21,54 @@ class ContextSummarizer: - """Decides when and how to compress the message history.""" - - # ------------------------------------------------------------------ - # Token estimation - # ------------------------------------------------------------------ + """Compresses message history, keeping tool calls and flags verbatim.""" @staticmethod def _estimate_tokens(messages: list) -> int: - """Rough token count — ~4 characters per token.""" total_chars = 0 for msg in messages: - content = msg.content if hasattr(msg, "content") else str(msg) + content = getattr(msg, "content", None) or str(msg) total_chars += len(content) + for tc in getattr(msg, "tool_calls", None) or []: + total_chars += len(getattr(tc, "fn_arguments_json", None) or "") return total_chars // 4 def should_summarize(self, messages: list, max_tokens: int = 150_000) -> bool: - """Return True when the estimated token count exceeds 80% of *max_tokens*.""" return self._estimate_tokens(messages) > int(max_tokens * 0.8) - # ------------------------------------------------------------------ - # Summarisation - # ------------------------------------------------------------------ - async def summarize(self, messages: list, llm: Any) -> list: - """Compress the oldest 70% of messages via *llm*, keeping the newest 30%. + """Compress oldest 70%, keeping tool calls/results and flags verbatim. - Messages that contain flag patterns are always preserved verbatim. + Returns [summary, ...preserved, ...recent_messages]. """ if not messages: return messages total = len(messages) split_idx = int(total * 0.7) - old_messages = messages[:split_idx] recent_messages = messages[split_idx:] - # Pull out flag-bearing messages from the old batch so they survive. - flag_messages: list = [] + preserved: list = [] to_summarize: list = [] for msg in old_messages: - content = msg.content if hasattr(msg, "content") else str(msg) - if _FLAG_PATTERNS.search(content): - flag_messages.append(msg) + content = getattr(msg, "content", None) or "" + if ( + _FLAG_PATTERNS.search(content) + or getattr(msg, "tool_calls", None) + or getattr(msg, "tool_response_call_id", None) + ): + preserved.append(msg) else: to_summarize.append(msg) - # Build the text block to hand to the LLM. text_block = "\n\n".join( - f"[{type(m).__name__}]: {m.content}" for m in to_summarize if hasattr(m, "content") + f"[{getattr(m, 'role', 'msg')}]: {getattr(m, 'content', '')}" + for m in to_summarize + if getattr(m, "content", None) ) - # Native LLM surface: `aask_text` returns a genai ``ChatResponse``. - # This works against both today's ``ChatModel`` (which delegates - # ``aask_text`` to its underlying client) and a bare ``AsyncLLMClient`` - # once the runtime is repointed off the ChatModel facade. from clearwing.llm.native import response_text summary_response = await llm.aask_text( @@ -82,11 +77,11 @@ async def summarize(self, messages: list, llm: Any) -> list: ) summary_text = response_text(summary_response) - # Reconstruct the message list. - result: list = [ - {"role": "system", "content": f"[Session Summary]\n{summary_text}"}, - ] - result.extend(flag_messages) - result.extend(recent_messages) + summary = ChatMessage("system", f"[Session Summary]\n{summary_text}") + result: list = [summary, *preserved, *recent_messages] + logger.info( + "context summarizer: %d msgs → %d (preserved=%d, recent=%d)", + total, len(result), len(preserved), len(recent_messages), + ) return result diff --git a/clearwing/sourcehunt/hunter.py b/clearwing/sourcehunt/hunter.py index 7a9b951a..02ab7e9f 100644 --- a/clearwing/sourcehunt/hunter.py +++ b/clearwing/sourcehunt/hunter.py @@ -14,7 +14,7 @@ import os import re import time -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -25,6 +25,7 @@ build_propagation_auditor_tools, ) from clearwing.core.events import EventBus, EventType +from clearwing.data.memory import ContextSummarizer from clearwing.llm import AsyncLLMClient, ChatMessage, NativeToolSpec, ToolCall from clearwing.llm.budget import spend_metadata from clearwing.observability.telemetry import CostTracker @@ -1379,6 +1380,7 @@ class NativeHunter: budget_usd: float = 0.0 # 0 = unlimited (bounded by max_steps) initial_user_message: str = "" # spec 006: override default first message max_repeated_skips: int = 15 # hard cap on total skipped degenerate-loop calls before giving up + summarizer: ContextSummarizer | None = field(default=None) def _should_stop(self, step: int, cost_usd: float) -> str | None: """Return a stop reason string, or None to continue.""" @@ -1449,6 +1451,11 @@ async def arun(self) -> HunterRunResult: }, ) with spend_metadata(model_call_id=model_call_id): + if self.summarizer and self.summarizer.should_summarize(messages): + pre = len(messages) + messages = await self.summarizer.summarize(messages, self.llm) + logger.info("Hunter context summarized: %d → %d messages", pre, len(messages)) + response = await self.llm.achat( messages=messages, system=self.prompt, @@ -1997,4 +2004,5 @@ def build_hunter_agent( max_steps=max_steps, agent_mode=agent_mode, budget_usd=budget_usd, + summarizer=ContextSummarizer(), ), ctx