diff --git a/tests/tools/test_session_search.py b/tests/tools/test_session_search.py index 3d68a9dec532a..9468c561977dc 100644 --- a/tests/tools/test_session_search.py +++ b/tests/tools/test_session_search.py @@ -19,6 +19,7 @@ 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 @@ def test_read_truncates_large_session(self, db): 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 diff --git a/tools/session_search_tool.py b/tools/session_search_tool.py index 1b7e60344ad3b..2589364000029 100644 --- a/tools/session_search_tool.py +++ b/tools/session_search_tool.py @@ -29,6 +29,14 @@ # 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,