Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
39baf35
Add new class variable for generation kwarg conversion and utils to u…
sjrl Aug 13, 2026
facd5f8
Merge branch 'main' into codex/chat-generator-parameter-mapping
sjrl Aug 13, 2026
d92b982
PR comments
sjrl Aug 13, 2026
6882115
Merge branch 'codex/chat-generator-parameter-mapping' of github.com:d…
sjrl Aug 13, 2026
64dddb8
refactor
sjrl Aug 13, 2026
f7ad0d3
Merge branch 'main' of github.com:deepset-ai/haystack into feat/summa…
sjrl Aug 17, 2026
ab2651a
updates
sjrl Aug 17, 2026
14ec1b5
fixes and better documentation on how the summarization progresses as…
sjrl Aug 17, 2026
e3d4a36
changes
sjrl Aug 17, 2026
9ace71b
some improvements
sjrl Aug 17, 2026
f86d596
pivot to using approximate_summary_tokens which is just an estimate u…
sjrl Aug 17, 2026
2552570
changes
sjrl Aug 18, 2026
3103893
fix tests and reno
sjrl Aug 18, 2026
2ae9650
improve reno
sjrl Aug 18, 2026
7b14536
refactoring tests
sjrl Aug 18, 2026
e06c4f8
more refactoring
sjrl Aug 18, 2026
dc1e336
test refinement
sjrl Aug 18, 2026
7e9cd70
test refinement
sjrl Aug 18, 2026
3821b2f
refinement
sjrl Aug 19, 2026
7ed3e1b
changes
sjrl Aug 19, 2026
e6b0680
remove smallets const
sjrl Aug 19, 2026
3ce957b
improve summarization logic so that we don't summarize messages outsi…
sjrl Aug 19, 2026
aa8b249
update test
sjrl Aug 19, 2026
682b187
improve rendered prompt for summarization
sjrl Aug 19, 2026
80c6fc4
improvements
sjrl Aug 19, 2026
c02eb27
PR comments
sjrl Aug 19, 2026
92d42ce
improve docs and shorten reno
sjrl Aug 19, 2026
1fb9c96
remove function
sjrl Aug 19, 2026
81866cb
Reduce number tokenizer calls
sjrl Aug 19, 2026
6c90527
PR comments
sjrl Aug 19, 2026
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
2 changes: 2 additions & 0 deletions haystack/hooks/compaction/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
_import_structure = {
"hooks": ["CompactionHook"],
"sliding_window": ["SlidingWindowCompactor"],
"summarization": ["SummarizationCompactor"],
"tool_result_pruning": ["ToolResultPruningCompactor"],
"types": ["Compactor"],
}

if TYPE_CHECKING:
from .hooks import CompactionHook as CompactionHook
from .sliding_window import SlidingWindowCompactor as SlidingWindowCompactor
from .summarization import SummarizationCompactor as SummarizationCompactor
from .tool_result_pruning import ToolResultPruningCompactor as ToolResultPruningCompactor
from .types import Compactor as Compactor
else:
Expand Down
511 changes: 511 additions & 0 deletions haystack/hooks/compaction/summarization.py

Large diffs are not rendered by default.

41 changes: 33 additions & 8 deletions haystack/token_counters/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@
# SPDX-License-Identifier: Apache-2.0

import json
from collections.abc import Callable

from haystack.dataclasses import ChatMessage, FileContent, ImageContent, TextContent
from haystack.dataclasses.chat_message import ChatMessageContentT, ToolCallResultContentT
from haystack.tools import ToolsType, flatten_tools_or_toolsets

# Builds the stand-in for message content that has no text form, such as an image.
_PlaceholderFn = Callable[[ChatMessageContentT], str]


def _non_text_placeholder(content: ChatMessageContentT) -> str:
"""A short stand-in, such as `<image>`, for message content that has no text form."""
Expand All @@ -18,26 +22,38 @@ def _non_text_placeholder(content: ChatMessageContentT) -> str:
return f"<{type(content).__name__}>"


def _tool_result_text(result: ToolCallResultContentT) -> str:
def _tool_result_text(result: ToolCallResultContentT, placeholder: _PlaceholderFn = _non_text_placeholder) -> str:
"""A tool result as a single string, with placeholders standing in for any non-text parts."""
if isinstance(result, str):
return result
return "".join(block.text if isinstance(block, TextContent) else _non_text_placeholder(block) for block in result)
return "".join(block.text if isinstance(block, TextContent) else placeholder(block) for block in result)


def _render_message(message: ChatMessage) -> str:
def _render_message(
message: ChatMessage, placeholder: _PlaceholderFn = _non_text_placeholder, include_tool_call_ids: bool = False
) -> str:
"""
One message as one or more lines of plain text.

Reasoning content is deliberately left out: providers discard it between turns, so it is not part of the context
being measured.

:param message: The message to render.
:param placeholder: Builds the stand-in for content that has no text form. The default is short and stable, which
is what a counter needs because the stand-in's own length is what gets measured. A caller rendering for a model
to read can pass one that describes the content instead.
:param include_tool_call_ids: Whether to include tool call IDs in the rendered message.
:returns: The rendered message.
Comment thread
sjrl marked this conversation as resolved.
"""
role = message.role.value
# A tool-result msg only carries tool_call_results, so it is rendered on its own and labelled with the tool that
# produced it.
if results := message.tool_call_results:
return "\n".join(
f"[tool:{result.origin.tool_name}{' (error)' if result.error else ''}] {_tool_result_text(result.result)}"
f"[tool:{result.origin.tool_name}"
f"{' id=' + result.origin.id if include_tool_call_ids and result.origin.id else ''}"
f"{' (error)' if result.error else ''}] "
f"{_tool_result_text(result.result, placeholder=placeholder)}"
for result in results
)

Expand All @@ -46,17 +62,26 @@ def _render_message(message: ChatMessage) -> str:
lines.append(f"[{role}] " + "\n".join(texts))
for call in message.tool_calls:
arguments = json.dumps(call.arguments, default=str, sort_keys=True)
lines.append(f"[{role} -> tool_call] {call.tool_name}({arguments})")
call_id = f" id={call.id}" if include_tool_call_ids and call.id else ""
lines.append(f"[{role} -> tool_call{call_id}] {call.tool_name}({arguments})")
# Images and files cost tokens too, so they need a stand-in rather than being skipped.
non_text: list[ChatMessageContentT] = [*message.images, *message.files]
for content in non_text:
lines.append(f"[{role}] {_non_text_placeholder(content)}")
lines.append(f"[{role}] {placeholder(content)}")
return "\n".join(lines) if lines else f"[{role}] <no content>"


def _rendered_conversation(messages: list[ChatMessage]) -> str:
def _rendered_conversation(
messages: list[ChatMessage],
*,
placeholder: _PlaceholderFn = _non_text_placeholder,
include_tool_call_ids: bool = False,
) -> str:
"""The whole conversation as one plain-text block, which is what a counter measures."""
return "\n".join(_render_message(message) for message in messages)
return "\n".join(
_render_message(message, placeholder=placeholder, include_tool_call_ids=include_tool_call_ids)
for message in messages
)


def _rendered_tools(tools: ToolsType | None) -> str:
Expand Down
2 changes: 1 addition & 1 deletion pydoc/hooks_api.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
loaders:
- search_path: [../haystack/hooks]
modules: ["protocol", "from_function", "compaction/hooks", "compaction/sliding_window",
"compaction/tool_result_pruning",
"compaction/summarization", "compaction/tool_result_pruning",
"compaction/types/protocol", "human_in_the_loop/dataclasses", "human_in_the_loop/hooks",
"human_in_the_loop/policies", "human_in_the_loop/strategies", "human_in_the_loop/user_interfaces",
"tool_result_offloading/hooks", "tool_result_offloading/policies", "tool_result_offloading/stores",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
Comment thread
sjrl marked this conversation as resolved.
features:
- |
Added the experimental ``SummarizationCompactor`` (use with ``CompactionHook``), which progressively summarizes a
conversation until it fits a target token budget, preserving useful context from long-running Agents instead of
dropping older messages.

It uses four summarization tiers in order until the target token budget is reached:

1. ``historical_turns``: Starting with the oldest, summarize as few complete historical turns as needed.
2. ``historical_summaries``: When no complete historical turns remain, combine as few of the oldest historical
summaries as needed.
3. ``current_task_steps``: Summarize the fewest oldest steps needed to reach the target while preserving the
``min_keep_steps`` newest steps.
4. ``current_task_summaries``: When no more steps can be summarized, combine as few of the current task's oldest
summaries as needed to reach the target.

.. code-block:: python

from typing import Annotated
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIResponsesChatGenerator
from haystack.hooks.compaction import CompactionHook, SummarizationCompactor
from haystack.tools import tool

@tool
def web_search(query: Annotated[str, "The search query"]) -> str:
"""Search the web for current information."""
return f"Search results for: {query}"

agent_generator = OpenAIResponsesChatGenerator(model="gpt-5.4")
summary_generator = OpenAIResponsesChatGenerator(model="gpt-5.4-nano")

compaction_hook = CompactionHook(
compactor=SummarizationCompactor(
chat_generator=summary_generator,
min_keep_steps=2,
approximate_summary_tokens=1_024,
),
context_window=400_000,
compact_at=0.7,
compact_to=0.4,
)

agent = Agent(
Comment thread
sjrl marked this conversation as resolved.
chat_generator=agent_generator,
tools=[web_search],
hooks={"before_llm": [compaction_hook]},
)
Loading
Loading