Files
hermes-agent/agent/context_compressor_tail.py
Teknium 882482b58a fix(compression): the active-request bridge obeys the post-handoff progress floor
When the first post-handoff tool group ends right before the active user
request, the progress floor lands on that request, and _compress_window's
assistant/tool bridge then pulled the cut back onto the group's assistant:
the window held only the handoff and compress() no-oped with
empty_post_handoff_window. The bridge now moves the cut back only down to
tail_cut_progress_floor (one rule); when the floor sits on the active request
the summary merges into that row through the existing merge path.
2026-10-28 20:39:24 -07:00

205 lines
12 KiB
Python

"""Lean-mode stale-tail demotion for ContextCompressor (old tail tool results become one-line recovery stubs), and
the Skill Safety Rule the compaction handoff carries whenever its transcript holds a skill prune marker."""
from __future__ import annotations
import re
from typing import Any
# Demote tool results older than the newest N rounds so the tail budget binds
# (the tool-group alignment floor otherwise keeps ~32K of tool output alive).
_LEAN_TAIL_KEEP_TOOL_ROUNDS = 6
_LEAN_TAIL_DEMOTE_MIN_CHARS = 1_500
# Matches what ``_lean_recovery_stub`` emits, so later passes treat a demoted row as already summarised: with a session
# id and a reload marker the stub can pass the Phase-1 prune floor, and re-summarising it would drop both pointers.
_DEMOTED_STUB_RE = re.compile(r"\[[^\s\]]* output demoted at compaction — [\d,]+ chars preserved in session history\.")
# :func:`lean_tail_budget`: the lean tail never claims more than this share of the trigger. A configured
# ``threshold_tokens`` (or aux summariser ceiling) far below the window left the 10K floor at 83% of a 12K trigger, and
# the 2.5%-of-window term at 216% of it on a 1M window, so head + summary + tail stayed over threshold and every pre-API
# pass refused as "would grow" (the window-side twin is ``TAIL_MAX_CONTEXT_FRACTION``).
TAIL_MAX_THRESHOLD_FRACTION = 0.50
def lean_tail_budget(context_length: int, threshold_tokens: int) -> int:
"""Lean-mode tail selection budget: 2.5% of the window clamped to the 10K floor / 25K cap, then held to
:data:`TAIL_MAX_THRESHOLD_FRACTION` of the trigger. At or below ``_MIN_SUMMARY_TOKENS`` the summary alone fills
the trigger, so no tail leaves room under it and only the floor is held: trimming the recency window there
would lose context without ending the loop."""
from agent.context_compressor import _MIN_SUMMARY_TOKENS, LEAN_TAIL_CAP_TOKENS, LEAN_TAIL_FLOOR_TOKENS
floor, window_term = LEAN_TAIL_FLOOR_TOKENS, min(LEAN_TAIL_CAP_TOKENS, int(context_length * 0.025))
if threshold_tokens > 0:
share = int(threshold_tokens * TAIL_MAX_THRESHOLD_FRACTION)
floor = min(floor, share)
if threshold_tokens > _MIN_SUMMARY_TOKENS:
window_term = min(window_term, share)
return max(floor, window_term)
def is_demoted_stub(content: str) -> bool:
"""True for a tail tool result already replaced by ``_lean_recovery_stub`` (plus an optional reload marker)."""
return len(content) < 1_000 and _DEMOTED_STUB_RE.match(content) is not None
def _lean_recovery_stub(tool_name: str, content_len: int, session_id: str) -> str:
"""One-line replacement for a demoted tail tool result."""
hint = f" Recover with session_search(query=..., session_id='{session_id}')" if session_id else ""
return (
f"[{tool_name or 'tool'} output demoted at compaction — {content_len:,} "
f"chars preserved in session history.{hint}]"
)
def skill_view_names_by_call_id(messages: list[dict[str, Any]]) -> dict[str, str]:
"""Map each ``skill_view`` tool-call id to the skill name it loaded."""
from agent.context_compressor import _json_dict, _tc_get
names: dict[str, str] = {}
for msg in messages:
if msg.get("role") != "assistant":
continue
for tc in msg.get("tool_calls") or []:
fn = _tc_get(tc, "function", {})
skill = _json_dict(_tc_get(fn, "arguments")).get("name") if _tc_get(fn, "name") == "skill_view" else None
if isinstance(skill, str) and skill:
names[str(_tc_get(tc, "id") or "")] = skill
return names
def demote_stale_tail_tools(
messages: list[dict[str, Any]], tail_start: int, session_id: str,
) -> tuple[list[dict[str, Any]], int]:
"""Demote tail tool results older than the newest ``_LEAN_TAIL_KEEP_TOOL_ROUNDS`` rounds to recovery stubs;
skill-marker rows untouched. A demoted ``skill_view`` body carries the canonical reload marker, as its
Phase-1 stub does, so the model does not act on instructions it no longer has (#32106). Returns a new list
(untouched rows shared, demoted copied) and the number of rows demoted."""
from agent.context_compressor import (
SKILL_PRUNED_MARKER_PREFIX, _is_summary_stub, _result_failure_suffix, _rewritten, _skill_pruned_marker,
)
rounds_seen = 0
protected: set[int] = set()
prev_idx = None
for i in (i for i in range(len(messages) - 1, tail_start - 1, -1) if messages[i].get("role") == "tool"):
rounds_seen += prev_idx is None or prev_idx - i > 1
prev_idx = i
if rounds_seen > _LEAN_TAIL_KEEP_TOOL_ROUNDS:
break
protected.add(i)
result = list(messages)
demoted = 0
skills: dict[str, str] | None = None
for i in range(tail_start, len(messages)):
msg = messages[i]
content = msg.get("content")
if msg.get("role") != "tool" or i in protected or not isinstance(content, str):
continue
if len(content) < _LEAN_TAIL_DEMOTE_MIN_CHARS or SKILL_PRUNED_MARKER_PREFIX in content or _is_summary_stub(content):
continue
stub = _lean_recovery_stub(msg.get("tool_name") or "", len(content), session_id)
skills = skill_view_names_by_call_id(messages) if skills is None else skills
if (skill := skills.get(str(msg.get("tool_call_id") or ""))) and not _result_failure_suffix(content):
stub += " " + _skill_pruned_marker(skill)
result[i] = _rewritten(msg, stub)
demoted += 1
return result, demoted
# Sent only where ``[SKILL_PRUNED:`` markers exist — after a compaction, riding its handoff — rather than in every
# fresh session's system prompt; adding it to a compacted transcript is the one sanctioned cache break.
SKILL_SAFETY_HEADING = "## Skill Safety Rule"
SKILL_SAFETY_RULE = (
f"{SKILL_SAFETY_HEADING}\n"
"A skill placeholder containing `[SKILL_PRUNED]` lost its content in context compression and is inaccessible — "
"reload it with skill_view(name='...') before acting on anything that depends on it. After reloading, ignore any "
"remaining `[SKILL_PRUNED]` markers for that same skill; they are historical artifacts of earlier compactions."
)
def tail_cut_pricer(compressor: Any, messages: list[dict[str, Any]], head_end: int):
"""Tail-cut walk price ``index -> tokens`` in which rows at or before the newest handoff cost nothing.
The handoff scan folds every row up to the newest in-transcript handoff into the summary window (the tail starts
past it), so none of them can stay in the protected tail. Charging them let the handoff row alone break the soft
ceiling: the walk stopped on it, the window held only that handoff, and compress() returned the
``empty_post_handoff_window`` no-op at every trigger while the session grew past its window."""
price = compressor._tail_row_pricer(messages)
newest = next(
(i for i in range(len(messages) - 1, head_end - 1, -1) if compressor._is_context_summary_message(messages[i])), -1,
)
return price if newest < 0 else (lambda i: 0 if i <= newest else price(i))
def tail_cut_progress_floor(compressor: Any, messages: list[dict[str, Any]], head_end: int, cut_idx: int) -> int:
"""Lowest tail cut that leaves the summary window a row the handoff scan keeps.
The scan drops standalone handoff rows from the window, so when every row before the cut (and up to the newest
handoff) is a handoff, the planned window is empty and compress() no-ops with ``empty_post_handoff_window``. That
happens when the cut lands inside the tool group right after the handoff and is aligned back onto the group's
assistant. The floor then moves past that group, unless doing so would take the newest row or the active user
request out of the tail; those cases keep the old ``head_end + 1`` floor. ``_compress_window``'s
assistant/tool bridge before the active request obeys the same floor, so the two never disagree: when the floor
sits on the active request, the summary merges into that row instead of the bridge emptying the window."""
floor = head_end + 1
newest = next(
(i for i in range(len(messages) - 1, head_end - 1, -1) if compressor._is_context_summary_message(messages[i])), -1,
)
if newest < 0 or not all(map(compressor._is_context_summary_message, messages[head_end:max(cut_idx, newest + 1)])):
return floor
raised = compressor._align_boundary_forward(messages, newest + 2)
last_user = compressor._find_last_user_message_idx(messages, newest + 1)
return floor if raised >= len(messages) or 0 <= last_user < raised else raised
def with_skill_safety_rule(
summary: str, head: list[dict[str, Any]], tail: list[dict[str, Any]],
) -> tuple[str, list[dict[str, Any]], list[dict[str, Any]]]:
"""``(summary, head, tail)`` carrying :data:`SKILL_SAFETY_RULE` exactly once when the summary or a kept row holds a
prune marker (summary-side ``## Pruned Skills`` markers, Phase-1 and lean-demoted ``skill_view`` stubs). A kept
prune-only stub's copy (:func:`with_pruned_row_skill_safety_rule`) moves onto the handoff: compaction is the
sanctioned rewrite, and the token-budget tail often keeps a stub the count-protected prune-only tail ruled."""
from agent.context_compressor import SKILL_PRUNED_MARKER_PREFIX, _content_text_for_contains
def unruled(rows):
return [{**m, "content": without_skill_safety_rule(m["content"])}
if isinstance(m, dict) and m.get("role") != "system" and isinstance(m.get("content"), str)
and SKILL_SAFETY_RULE in m["content"] else m for m in rows]
head, tail = unruled(head), unruled(tail)
texts = (summary, *(_content_text_for_contains(m.get("content")) for m in (*head, *tail) if isinstance(m, dict)))
if any(SKILL_SAFETY_RULE in text for text in texts) or not any(SKILL_PRUNED_MARKER_PREFIX in t for t in texts):
return summary, head, tail
return f"{summary}{_SKILL_SAFETY_SUFFIX}", head, tail
_SKILL_SAFETY_SUFFIX = f"\n\n{SKILL_SAFETY_RULE}"
def without_skill_safety_rule(content: str) -> str:
"""*content* minus an attached :data:`SKILL_SAFETY_RULE` (a ruled prune stub is still a stub)."""
return content.replace(_SKILL_SAFETY_SUFFIX, "")
def elide_keeping_skill_safety_rule(content: str, limit: int) -> str:
"""``elide(content, limit)`` that keeps an attached rule: the head-only cut would drop it from a long handoff
while marker rows beside it survive (anti-growth salvage)."""
from agent.compression_marker import elide
if SKILL_SAFETY_RULE not in content:
return elide(content, limit)
return elide(without_skill_safety_rule(content), limit - len(_SKILL_SAFETY_SUFFIX)) + _SKILL_SAFETY_SUFFIX
def with_pruned_row_skill_safety_rule(original: list[dict[str, Any]], pruned: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""*pruned* (a prune-only rewrite of *original*) with the rule on the first row it turned into a prune marker,
when no row carries the rule yet: prune-only has no handoff for :func:`with_skill_safety_rule` to ride."""
from agent.context_compressor import SKILL_PRUNED_MARKER_PREFIX, _content_text_for_contains
if any(SKILL_SAFETY_RULE in _content_text_for_contains(m.get("content")) for m in pruned if isinstance(m, dict)):
return pruned
for i, (before, after) in enumerate(zip(original, pruned)):
content = after.get("content") if isinstance(after, dict) else None
if isinstance(content, str) and SKILL_PRUNED_MARKER_PREFIX in content and content != before.get("content"):
return [*pruned[:i], {**after, "content": content + _SKILL_SAFETY_SUFFIX}, *pruned[i + 1:]]
return pruned