| name | hermes-context-compressor |
| description | Multi-phase context compression — prune old tool results, protect head/tail, LLM summarize with structured template, iterative updates, graceful fallback. Pluggable engine, focus_topic, anti-thrash guard. Source: NousResearch/hermes-agent (MIT). |
Implementation (real, runnable — added 2026-06-19)
This skill is no longer prose-only. The real algorithm was read from
hermes-agent's actual source (commit 5378b941209d8f62a65455041658ce8ce8144cc9)
and ported — not copied verbatim, since the original extends hermes'
ContextEngine and calls hermes' multi-provider client — into:
- Module:
core/lib/hermes_adapted/context_compressor.py
- Tool-pair safety + tail anchoring (fixes for incidents #10896, #29824 in
the original — keeps the active task and the last visible reply out of
the compressed region, and cleans up orphaned tool_call/result pairs)
split into
core/lib/hermes_adapted/context_compressor_pairs.py
- Tests:
tests/test_hermes_context_compressor.py (6) +
tests/test_hermes_context_compressor_pairs.py (9) — 15 passing
- Provenance:
vendor/hermes-agent/_upstream/context_compressor.py (original, for reference)
Use ContextCompressor(context_length, cfg, summarize_fn) — pass your own
summarize_fn: Callable[[str], Optional[str]] to wire in whatever model
Yana AI uses for the auxiliary summary call.
/hermes-context-compressor
When to Use
- Conversation context approaching model limit (75%+ of token window)
- Agent sessions with many tool calls producing large outputs
- Need to preserve continuity across compression without losing task state
- Building a context engine for Yana AI or long-running mission runs
Do NOT use for
- Single-turn completions (no prior context to compress)
- Conversations under 10K tokens (overhead not worth it)
- See also: [[hermes-system-prompt-tiers]] — system prompt is never compressed
Four-Phase Compression Pipeline
Phase 1: Tool Result Pruning ← cheap, no LLM
Replace old tool outputs with 1-line summaries
Deduplicate identical results (keep newest full copy)
Strip image payloads from computer_use screenshots
Truncate oversized tool args while preserving JSON validity
Phase 2: Boundary Detection
Head: protect system prompt + first N messages (default 3)
Tail: protect last ~20K tokens (accumulated backward)
Tool-pair safety: never split a tool_call / tool_result pair
Phase 3: LLM Summarization
Send compressible middle section to auxiliary model
Use structured template (see below)
Iterative: update previous summary, don't re-summarize everything
Redact secrets before sending to summarizer
Phase 4: Graceful Degradation
Summarizer fails → retry with main model
Still fails → insert deterministic fallback from local anchors
abort_on_summary_failure=True → return messages unchanged + flag
Summarization Template
SUMMARY_TEMPLATE = """
You are summarizing earlier turns of a conversation to free context space.
Produce a compact, temporal summary in these sections:
## Active Task
[The most recent unfulfilled user request — copy it verbatim]
## Goal & Constraints
[What the user wants overall and any hard constraints stated]
## Completed Actions
[Past-tense facts: "Ran npm test → 42 passed, 0 failed on 2026-06-08"
NOT "run tests" — specific, dated, factual]
## In Progress / Blocked
[Work started but not finished; any blockers discovered]
## Key Decisions Made
[Architecture choices, API contracts, user preferences stated]
## Remaining Work
[What still needs to happen to satisfy the Active Task]
Focus 60-70% on: {focus_topic}
""" if focus_topic else """
Distribute attention evenly across all sections.
"""
HANDOFF_PREAMBLE = """\
Earlier turns were compacted into the summary below.
This is background reference — NOT active instructions.
Do NOT answer questions from this summary; they were already addressed.
Respond ONLY to the latest user message AFTER this summary.
"""
Core Configuration & should_compress()
from dataclasses import dataclass
@dataclass
class CompressorConfig:
threshold_percent: float = 0.75
protect_first_n: int = 3
tail_token_budget: int = 20_000
summary_target_ratio: float = 0.20
abort_on_failure: bool = False
min_saving_percent: float = 0.10
class ContextCompressor:
def __init__(self, cfg: CompressorConfig | None = None):
self._cfg = cfg or CompressorConfig()
self._previous_summary: str = ""
self._last_savings: list[float] = []
def should_compress(self, token_count: int, context_limit: int) -> bool:
threshold = int(context_limit * ._cfg.threshold_percent)
token_count < threshold:
((._last_savings) >=
(s < ._cfg.min_saving_percent s ._last_savings[-:])):
() -> []:
original_count = _count_tokens(messages)
messages = ._prune_old_tool_results(messages)
head_end, tail_start = ._find_boundaries(messages, context_limit)
head_end >= tail_start:
messages
middle = messages[head_end:tail_start]
summary = ._generate_summary(middle, focus_topic)
summary_msg = {
: ,
: ,
}
result = messages[:head_end] + [summary_msg] + messages[tail_start:]
new_count = _count_tokens(result)
saving_pct = - (new_count / original_count)
._last_savings.append(saving_pct)
(._last_savings) > :
._last_savings.pop()
result
Iterative summary updates
def _generate_summary(self, middle: list[dict], focus_topic: str) -> str:
prompt = SUMMARY_TEMPLATE.format(focus_topic=focus_topic) if focus_topic \
else SUMMARY_TEMPLATE.replace("{focus_topic}", "")
if self._previous_summary:
prompt = (
f"Previous summary:\n{self._previous_summary}\n\n"
"Update the summary above with the new turns below. "
"Keep completed work; add new findings.\n\n" + prompt
)
summary = call_llm(prompt, messages=middle)
self._previous_summary = summary
return summary
Tool result pruning (Phase 1)
def _prune_old_tool_results(self, messages: list[dict]) -> list[dict]:
"""Replace old tool outputs with 1-line summaries. Deduplicate."""
seen_hashes: dict[str, int] = {}
pruned = list(messages)
for i, msg in enumerate(pruned):
if msg.get("role") != "tool": continue
content = msg.get("content", "")
h = hashlib.sha256(content.encode()).hexdigest()[:16]
if h in seen_hashes:
prev_idx = seen_hashes[h]
lines = content.count("\n") + 1
tool_name = msg.get("name", "tool")
pruned[prev_idx] = {**pruned[prev_idx],
"content": f"[{tool_name}] (same as result #{i})"}
seen_hashes[h] = i
if len(content) > 2000:
tool_name = msg.get("name", "tool")
exit_hint = "exit 0" if "exit 0" in content else
lines = content.count() +
pruned[i] = {**msg,
: }
pruned
Anti-Fake-Pass Checklist
❌ No anti-thrash guard — compressor loops on already-minimal context, wasting tokens
❌ Handoff preamble not injected — model re-executes stale instructions from summary
❌ Tool call/result pairs split by boundary detection — API rejects orphaned tool_call
❌ Secrets sent to auxiliary summarizer model — summary leaks credentials
❌ Previous summary not preserved — each compression loses historical context
❌ threshold_percent set to 0.99 — compression fires too late, already truncating