Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 28 additions & 33 deletions clearwing/data/memory/summarizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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(
Expand All @@ -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
10 changes: 9 additions & 1 deletion clearwing/sourcehunt/hunter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1997,4 +2004,5 @@ def build_hunter_agent(
max_steps=max_steps,
agent_mode=agent_mode,
budget_usd=budget_usd,
summarizer=ContextSummarizer(),
), ctx
Loading