|
| 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) |
0 commit comments