Skip to content

Commit f150d3a

Browse files
committed
feat: add context compaction hook
1 parent 0b7dd85 commit f150d3a

13 files changed

Lines changed: 1267 additions & 2 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
import sys
6+
from typing import TYPE_CHECKING
7+
8+
from lazy_imports import LazyImporter
9+
10+
_import_structure = {
11+
"hooks": ["ContextCompactionHook"],
12+
"sliding_window": ["SlidingWindowCompactor"],
13+
"types": ["CompactionBudget", "Compactor"],
14+
}
15+
16+
if TYPE_CHECKING:
17+
from .hooks import ContextCompactionHook as ContextCompactionHook
18+
from .sliding_window import SlidingWindowCompactor as SlidingWindowCompactor
19+
from .types import CompactionBudget as CompactionBudget
20+
from .types import Compactor as Compactor
21+
else:
22+
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)

haystack/hooks/compaction/hooks.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
from typing import Any
6+
7+
from haystack import logging
8+
from haystack.components.agents.state.state import State
9+
from haystack.components.agents.state.state_utils import replace_values
10+
from haystack.core.serialization import component_to_dict, default_from_dict, default_to_dict
11+
from haystack.dataclasses import ChatMessage
12+
from haystack.hooks.compaction.types import CompactionBudget, Compactor
13+
from haystack.hooks.compaction.utils import _estimated_context_tokens, _last_assistant_end
14+
from haystack.token_counters import TiktokenCounter, TokenCounter
15+
from haystack.utils.deserialization import deserialize_component_inplace
16+
from haystack.utils.experimental import _experimental
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
@_experimental
22+
class ContextCompactionHook:
23+
"""
24+
Compacts an Agent's conversation once it fills too much of the model's context window.
25+
26+
This `before_llm` Agent hook estimates the size of the conversation before each chat-generator call and, once it
27+
reaches `compact_at` of the window, hands it to a `Compactor` to bring back down to `compact_to`. Register it on an
28+
`Agent` under the `before_llm` hook point:
29+
30+
```python
31+
from haystack.components.agents import Agent
32+
from haystack.components.generators.chat import OpenAIChatGenerator
33+
from haystack.hooks.compaction import ContextCompactionHook, SlidingWindowCompactor
34+
35+
hook = ContextCompactionHook(
36+
compactor=SlidingWindowCompactor(),
37+
context_window=200_000,
38+
compact_at=0.7,
39+
compact_to=0.4,
40+
)
41+
agent = Agent(
42+
chat_generator=OpenAIChatGenerator(model="gpt-5.4-nano"),
43+
tools=[web_search],
44+
hooks={"before_llm": [hook]},
45+
max_agent_steps=50,
46+
)
47+
```
48+
49+
Size is measured by anchoring on the `context_tokens` state key - the chat generator's own count of the request it
50+
was sent plus its reply, which already covers the system prompt, the tool schemas, and the provider's chat-template
51+
overhead - and counting only the messages appended since that call. The estimate is therefore exact for the bulk of
52+
the conversation and approximate only for its most recent messages.
53+
54+
Compaction is lossy by nature, so the Agent works from a shorter record of the run afterwards. What survives is up
55+
to the compactor.
56+
"""
57+
58+
allowed_hook_points = ("before_llm",)
59+
60+
def __init__(
61+
self,
62+
compactor: Compactor,
63+
*,
64+
context_window: int,
65+
compact_at: float = 0.7,
66+
compact_to: float = 0.4,
67+
token_counter: TokenCounter | None = None,
68+
) -> None:
69+
"""
70+
Initialize the hook with a compactor and the window it has to fit in.
71+
72+
:param compactor: The `Compactor` that rewrites the conversation.
73+
:param context_window: The model's context window in tokens. Everything else is a fraction of this, so moving to
74+
a different model means changing only this number.
75+
:param compact_at: The fraction of the window at which compaction starts. Leave room above it for the reply and
76+
the tool results it triggers, which land on top of what was measured.
77+
:param compact_to: The fraction of the window compaction aims to bring the conversation down to. Lower means
78+
compacting less often but losing more each time.
79+
:param token_counter: The `TokenCounter` used to size the messages the chat generator has not reported on yet.
80+
Defaults to `TiktokenCounter`, which needs `tiktoken` installed.
81+
:raises ValueError: If `context_window` is not positive, or the fractions are not
82+
`0 < compact_to < compact_at <= 1`.
83+
"""
84+
if context_window < 1:
85+
raise ValueError(f"`context_window` must be a positive number of tokens, got {context_window}.")
86+
if not 0 < compact_to < compact_at <= 1:
87+
raise ValueError(
88+
f"`compact_at` and `compact_to` must satisfy 0 < compact_to < compact_at <= 1, got "
89+
f"compact_at={compact_at} and compact_to={compact_to}. A target at or above the trigger would leave "
90+
f"the conversation over the trigger after compacting, so it would be attempted again every step."
91+
)
92+
self.compactor = compactor
93+
self.context_window = context_window
94+
self.compact_at = compact_at
95+
self.compact_to = compact_to
96+
self.token_counter = token_counter or TiktokenCounter()
97+
98+
@property
99+
def _target_tokens(self) -> int:
100+
"""The size compaction aims to bring the conversation down to."""
101+
return int(self.context_window * self.compact_to)
102+
103+
def run(self, state: State) -> None:
104+
"""
105+
Compact `state.data["messages"]` if the conversation fills too much of the window.
106+
107+
:param state: The Agent's live `State`. Read to decide whether to compact, and rewritten in place when the
108+
compactor returns a compacted conversation.
109+
:returns: None. The hook mutates `state` in place.
110+
"""
111+
if not self._over_threshold(state):
112+
return
113+
self._apply(state, self.compactor.compact(self._messages(state), self._budget(state)))
114+
115+
async def run_async(self, state: State) -> None:
116+
"""
117+
Asynchronously compact `state.data["messages"]` if the conversation fills too much of the window.
118+
119+
:param state: The Agent's live `State`. Read to decide whether to compact, and rewritten in place when the
120+
compactor returns a compacted conversation.
121+
:returns: None. The hook mutates `state` in place.
122+
"""
123+
if not self._over_threshold(state):
124+
return
125+
self._apply(state, await self.compactor.compact_async(self._messages(state), self._budget(state)))
126+
127+
@staticmethod
128+
def _messages(state: State) -> list[ChatMessage]:
129+
"""The conversation held in `State`, or an empty list."""
130+
return state.data.get("messages") or []
131+
132+
def _estimated_tokens(self, state: State) -> int:
133+
"""The estimated size of the whole context, anchored on what the chat generator reported."""
134+
return _estimated_context_tokens(self._messages(state), state.data.get("context_tokens", 0), self.token_counter)
135+
136+
def _budget(self, state: State) -> CompactionBudget:
137+
"""The size the messages should come in under, and the counter to measure with."""
138+
# The target covers the whole context, but a compactor can only remove messages. Subtract what it cannot touch -
139+
# the tool schemas and chat-template overhead the reported count includes - or it would remove far too little.
140+
overhead = self._estimated_tokens(state) - self.token_counter.count(self._messages(state))
141+
return CompactionBudget(target_tokens=max(self._target_tokens - overhead, 0), counter=self.token_counter)
142+
143+
def _over_threshold(self, state: State) -> bool:
144+
"""Whether the context has reached `compact_at` of the window, so compaction should be attempted."""
145+
return self._estimated_tokens(state) >= self.context_window * self.compact_at
146+
147+
def _apply(self, state: State, compacted: list[ChatMessage] | None) -> None:
148+
"""Write a compacted conversation back into `State`, or do nothing if the compactor declined."""
149+
if compacted is None:
150+
return
151+
messages_before = len(self._messages(state))
152+
state.set("messages", compacted, handler_override=replace_values)
153+
# Re-estimate rather than reset to 0, which would claim the context is empty when its size is roughly known.
154+
# Counting only through the last assistant message keeps the key's meaning intact, so a later read does not
155+
# count the trailing messages a second time.
156+
state.set("context_tokens", self.token_counter.count(compacted[: _last_assistant_end(compacted)]))
157+
logger.debug(
158+
"Compacted the Agent's conversation at step {step} from {before} to {after} messages, targeting {target} "
159+
"tokens.",
160+
step=state.data.get("step_count", 0),
161+
before=messages_before,
162+
after=len(compacted),
163+
target=self._target_tokens,
164+
)
165+
166+
def warm_up(self) -> None:
167+
"""Warm up the token counter and the compactor, which may hold resources such as a Chat Generator."""
168+
if hasattr(self.token_counter, "warm_up"):
169+
self.token_counter.warm_up()
170+
if hasattr(self.compactor, "warm_up"):
171+
self.compactor.warm_up()
172+
173+
async def warm_up_async(self) -> None:
174+
"""Warm up the token counter and the compactor on the serving event loop."""
175+
if hasattr(self.token_counter, "warm_up"):
176+
self.token_counter.warm_up()
177+
warm_up_async = getattr(self.compactor, "warm_up_async", None)
178+
if warm_up_async is not None:
179+
await warm_up_async()
180+
elif hasattr(self.compactor, "warm_up"):
181+
self.compactor.warm_up()
182+
183+
def close(self) -> None:
184+
"""Release the compactor's resources."""
185+
if hasattr(self.compactor, "close"):
186+
self.compactor.close()
187+
188+
async def close_async(self) -> None:
189+
"""Release the compactor's async resources."""
190+
close_async = getattr(self.compactor, "close_async", None)
191+
if close_async is not None:
192+
await close_async()
193+
elif hasattr(self.compactor, "close"):
194+
self.compactor.close()
195+
196+
def to_dict(self) -> dict[str, Any]:
197+
"""
198+
Serialize the hook, including its compactor and token counter.
199+
200+
:returns: A dictionary representation of the hook.
201+
"""
202+
return default_to_dict(
203+
self,
204+
compactor=component_to_dict(obj=self.compactor, name="compactor"),
205+
context_window=self.context_window,
206+
compact_at=self.compact_at,
207+
compact_to=self.compact_to,
208+
token_counter=component_to_dict(obj=self.token_counter, name="token_counter"),
209+
)
210+
211+
@classmethod
212+
def from_dict(cls, data: dict[str, Any]) -> "ContextCompactionHook":
213+
"""
214+
Deserialize the hook, reconstructing its compactor and token counter.
215+
216+
:param data: A dictionary representation produced by `to_dict`.
217+
:returns: The deserialized `ContextCompactionHook`.
218+
"""
219+
init_params = data.get("init_parameters", {})
220+
for key in ("compactor", "token_counter"):
221+
if init_params.get(key) is not None:
222+
deserialize_component_inplace(init_params, key=key)
223+
return default_from_dict(cls, data)
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
from typing import Any
6+
7+
from haystack.core.serialization import default_to_dict
8+
from haystack.dataclasses import ChatMessage
9+
from haystack.hooks.compaction.types import CompactionBudget, Compactor
10+
from haystack.hooks.compaction.utils import _COMPACTION_META_KEY, _compaction_bounds
11+
from haystack.utils.experimental import _experimental
12+
13+
# Placeholder a custom omission note may include to have the number of removed messages substituted in.
14+
_NUM_REMOVED_PLACEHOLDER = "{num_removed}"
15+
16+
_DEFAULT_OMISSION_NOTE = (
17+
f"[{_NUM_REMOVED_PLACEHOLDER} earlier messages were removed from this conversation to free up context and cannot "
18+
f"be recovered.]"
19+
)
20+
21+
22+
@_experimental
23+
class SlidingWindowCompactor(Compactor):
24+
"""
25+
Keeps the leading system messages and as many recent messages as the budget allows, dropping everything in between.
26+
27+
An `omission_note` is left in place of what was removed. Cheap but lossy: whatever the Agent learned outside the
28+
retained window is gone, so it suits runs whose turns are largely independent.
29+
30+
```python
31+
from haystack.components.agents import Agent
32+
from haystack.components.generators.chat import OpenAIChatGenerator
33+
from haystack.hooks.compaction import ContextCompactionHook, SlidingWindowCompactor
34+
35+
hook = ContextCompactionHook(
36+
compactor=SlidingWindowCompactor(), context_window=200_000, compact_at=0.7, compact_to=0.4
37+
)
38+
agent = Agent(
39+
chat_generator=OpenAIChatGenerator(model="gpt-5.4-nano"),
40+
tools=[web_search],
41+
hooks={"before_llm": [hook]},
42+
)
43+
```
44+
"""
45+
46+
def __init__(self, *, min_keep_messages: int = 2, omission_note: str | None = _DEFAULT_OMISSION_NOTE) -> None:
47+
"""
48+
Initialize the compactor.
49+
50+
How much is kept comes from the budget the hook supplies, not from here.
51+
52+
:param min_keep_messages: The fewest recent messages to keep when the budget cannot afford more, so a target set
53+
too low still leaves the Agent something to work from. Must be at least 1: the Agent may be mid-step with a
54+
pending tool call whose result is appended right after compaction.
55+
:param omission_note: The user message left in place of what was removed, or None to remove the messages
56+
silently. Include `{num_removed}` to have the number of removed messages substituted in.
57+
:raises ValueError: If `min_keep_messages` is less than 1.
58+
"""
59+
if min_keep_messages < 1:
60+
raise ValueError(
61+
f"`min_keep_messages` must be at least 1, got {min_keep_messages}. Keeping no messages would drop a "
62+
f"pending tool call whose result the Agent appends after compaction, leaving it orphaned."
63+
)
64+
self.min_keep_messages = min_keep_messages
65+
self.omission_note = omission_note
66+
67+
def compact(self, messages: list[ChatMessage], budget: CompactionBudget) -> list[ChatMessage] | None:
68+
"""
69+
Drop the messages between the leading system block and the retained window.
70+
71+
:param messages: The conversation to compact, oldest to newest.
72+
:param budget: The size to aim for and the counter to measure with.
73+
:returns: The system messages, an omission note if one is configured, and the retained window; or None when
74+
there is nothing worth removing.
75+
"""
76+
start, end = _compaction_bounds(messages, budget.target_tokens, budget.counter, self.min_keep_messages)
77+
removed = end - start
78+
# With a note, removing a single message just swaps it for the note.
79+
if removed < (2 if self.omission_note else 1):
80+
return None
81+
82+
compacted = list(messages[:start])
83+
if self.omission_note:
84+
compacted.append(
85+
# A user message rather than a system one: providers that hoist system messages into a separate
86+
# top-level field would move the note away from the point in the conversation it describes.
87+
ChatMessage.from_user(
88+
# `replace` rather than `format`, so a note carrying braces of its own cannot raise.
89+
self.omission_note.replace(_NUM_REMOVED_PLACEHOLDER, str(removed)),
90+
meta={
91+
_COMPACTION_META_KEY: {
92+
"strategy": "sliding_window",
93+
"removed_messages": removed,
94+
"kept_messages": len(messages) - end,
95+
}
96+
},
97+
)
98+
)
99+
compacted.extend(messages[end:])
100+
return compacted
101+
102+
def to_dict(self) -> dict[str, Any]:
103+
"""
104+
Serialize the compactor.
105+
106+
:returns: A dictionary representation of the compactor.
107+
"""
108+
return default_to_dict(self, min_keep_messages=self.min_keep_messages, omission_note=self.omission_note)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
from .protocol import CompactionBudget, Compactor
6+
7+
__all__ = ["CompactionBudget", "Compactor"]

0 commit comments

Comments
 (0)