fix(session_search): cap per-message content on the read shape

#69334 capped discovery bookends (1200 chars) and scroll windows (4000)
via _shape_message(max_content_len=...) but _read_session still shaped
rows with no cap, so one archived tool result stored as a message came
back whole - a single read returned 74K chars and took a request from
~50K to ~89K tokens in one step.

Cap read-shape messages at 2000 chars with the same content_truncated /
original_content_chars metadata, and add a regression test.

Fixes #114344
This commit is contained in:
Simon Crouch
2026-09-17 19:42:40 +01:00
committed by Teknium
parent a834988165
commit 17869e8bd7
2 changed files with 26 additions and 1 deletions

View File

@@ -19,6 +19,7 @@ from hermes_state import SessionDB
from tools.session_search_tool import (
SESSION_SEARCH_SCHEMA,
_format_timestamp,
_READ_MAX_CONTENT,
_is_compacted_message,
_resolve_to_parent,
_session_link,
@@ -491,6 +492,22 @@ class TestReadShape:
assert result["truncated"] is True
assert len(result["messages"]) == 30 # head 20 + tail 10
def test_read_caps_oversized_message_content(self, db):
# #114344: a huge archived tool result stored as a message must not come
# back whole on the read shape - discovery/scroll already cap (#69334).
db.create_session("s_huge", source="cli")
db.append_message("s_huge", role="user", content="run it")
db.append_message("s_huge", role="assistant", content="x" * 80_000)
db.append_message("s_huge", role="user", content="thanks")
db._conn.commit()
result = json.loads(session_search(session_id="s_huge", db=db))
assert result["mode"] == "read"
assert result["truncated"] is False # 3 messages, count-wise it all fits
big = next(m for m in result["messages"] if m.get("content_truncated"))
assert len(big["content"]) <= _READ_MAX_CONTENT + 1 # cap plus ellipsis
assert big["original_content_chars"] == 80_000
assert sum(len(m.get("content") or "") for m in result["messages"]) < 5_000
# =========================================================================
# Session links — the value the agent writes to point the user at a session

View File

@@ -29,6 +29,14 @@ _HIDDEN_SESSION_SOURCES = ("kanban", "subagent", "tool")
# Demoting — not excluding — keeps cron content reachable when it's the only match, while interactive
# sessions always win when both match.
_DEMOTED_SESSION_SOURCES = ("cron",)
# Read-shape per-message content cap. #69334 capped discovery bookends (1200) and
# scroll windows (4000) but left ``_read_session`` returning whole messages, so a
# single archived tool result stored as a message could come back verbatim - one
# read returned 74K chars and took a request from ~50K to ~89K tokens in a step.
# Bounding message COUNT (head/tail) is not enough when content per message is
# unbounded; the agent can scroll around a message for detail (#114344).
_READ_MAX_CONTENT = 2000
# FTS rows scanned before dedup-by-lineage — well above the distinct sessions a query
# returns, so interactive matches buried under cron hits survive the demotion pass.
_DISCOVER_SCAN_LIMIT = 300
@@ -441,7 +449,7 @@ def _read_session(db, session_id: str, head: int = 20, tail: int = 10, link_prof
session_id)
if err:
return err
shaped = [_shape_message(m) for m in rows]
shaped = [_shape_message(m, max_content_len=_READ_MAX_CONTENT) for m in rows]
total, truncated = len(shaped), len(shaped) > head + tail
return _ok(mode="read", session_id=session_id, link=_session_link(session_id, link_profile),
session_meta=_session_meta_block(meta), message_count=total, truncated=truncated,