⭐️ Highlights
🐍 Python 3.15 Support, Python 3.10 Dropped
Haystack now officially supports Python 3.15. Python 3.10 reaches end of life in October 2026, so the minimum supported version is now 3.11. ⚠️
📦 ToolResultOffloadCompactor: Compact Agent Context Without Losing Tool Results
The experimental ToolResultOffloadCompactor is a new compaction strategy for CompactionHook. When compaction is triggered, it doesn't drop or summarize older tool results. It writes them to a ToolResultStore and leaves a reference in their place. The Agent can read the full output back on demand with a read tool scoped to the same store. Fresh results stay in context until compaction kicks in, so short runs pay nothing extra.
from typing import Annotated
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIResponsesChatGenerator
from haystack.hooks.compaction import CompactionHook, ToolResultOffloadCompactor
from haystack.hooks.tool_result_offloading import FileSystemToolResultStore
from haystack.tools import tool
store = FileSystemToolResultStore(root="tool_results")
@tool
def read_offloaded_result(path: Annotated[str, "Path of an offloaded tool result"]) -> str:
"""Read back the full content of an offloaded tool result."""
content = store.read(path)
if isinstance(content, bytes):
return f"'{path}' holds {len(content)} bytes of binary content and cannot be read as text."
return content
agent = Agent(
chat_generator=OpenAIResponsesChatGenerator(model="gpt-5.4-nano"),
tools=[web_search, read_offloaded_result],
hooks={"before_llm": [CompactionHook(compactor=ToolResultOffloadCompactor(store=store), context_window=400_000)]},
)⬆️ Upgrade Notes
-
The
page_numbermetadata field now means the same thing in every splitter: the page the chunk's text starts on. Two cases change:RecursiveDocumentSplitterreported the page a chunk ended on. A chunk spanning a page break now reports the page it starts on, matchingDocumentSplitterandMarkdownHeaderSplitter.DocumentSplitter,MarkdownHeaderSplitterandEmbeddingBasedDocumentSplitterreported the previous page for a chunk that began with one or more form feeds (\f), even though all of the chunk's text was on the following page. Such chunks now report the page their text is on. A chunk made up entirely of page breaks is an empty page and keeps the page it starts on, so blank pages emitted bysplit_by="page"still get their own number.
If you display
page_numberto users or store it alongside embeddings, re-index documents that contain form feeds to pick up the corrected values. -
RecursiveDocumentSplitterwithsplit_unit="word"andsplit_overlapgreater than 0 now returns chunks copied from the document as they are. Previously it rebuilt them from their words joined by single spaces, which added spaces the document does not have, collapsed line breaks and repeated spaces, and could drop form feeds (\f). The overlap also counts words the waysplit_lengthdoes, as text separated by spaces, so where words are separated only by line or page breaks, chunks can end at different words than before. Re-index documents split this way if you need their chunks to match the source text. -
RecursiveDocumentSplitternow keeps the whitespace between sentences whensentence_splitter_paramssetskeep_white_spacestoFalse. That is the sentence tokenizer's default, so this applies wheneversentence_splitter_paramsis passed without"keep_white_spaces": True. The tokenizer leaves that whitespace out, and the chunks used to glue their sentences together, as in"One two.Three four.": the glued words counted as one towardsplit_length, page breaks (\f) between sentences were lost, andsplit_idx_startandpage_numberfell behind the chunk's real start. Chunks are now copied from the document whateverkeep_white_spacesis set to; it only changes the rules the tokenizer uses to decide where a sentence ends. Re-index documents split with this setting to pick up the corrected chunks. -
Dropped support for Python 3.10 since it reaches End of Life (EOL) in October 2026. The minimum supported Python version is now 3.11. If you are on Python 3.10, upgrade your Python version before upgrading Haystack.
-
FilterPolicy.MERGEnow intersects initialization and runtime logical filters when both useORorNOT. Callers that relied on the previous broader result should construct a single explicitORfilter containing all alternatives and pass it withFilterPolicy.REPLACEinstead. -
ChatMessage.from_openai_dict_formatnow raises aValueErrorfor content parts it can't convert: images referenced by a URL such ashttps://example.com/image.png(only images embedded as base64 data URLs, likedata:image/png;base64,..., are supported), files referenced byfile_id, and other part types such asinput_audio. Previously, such messages were accepted because the whole list was stored as a single text, andChatMessage.to_openai_dict_formatsent that list back unchanged, so forwarding them to OpenAI worked by accident. You're affected if the OpenAI messages you convert contain any of these parts. To keep sending images by URL, download them and build the message yourself, for exampleChatMessage.from_user(content_parts=["Describe this image", ImageContent.from_url(url)]). Send files inline as base64file_datainstead of referencing them byfile_id. Extra keys on content parts, such ascache_control, are no longer passed through, so prompt caching through OpenRouter or LiteLLM stops for these messages. -
HTMLToDocumentnow defaults toencoding=None, delegating HTML encoding detection to Trafilatura. Setencoding="utf-8"to retain explicit UTF-8 decoding. -
Previously,
HTMLToDocumentused theencodingvalue in a source ByteStream's metadata to decode its content, overriding the converter's encoding setting. This metadata no longer affects decoding. If you relied on it, pass the encoding to the converter instead, for exampleHTMLToDocument(encoding="latin-1"). -
The default model for
OpenAITextEmbedder,OpenAIDocumentEmbedder,AzureOpenAITextEmbedder, andAzureOpenAIDocumentEmbedderhas been changed fromtext-embedding-ada-002totext-embedding-3-small.text-embedding-3-smallis roughly 5x cheaper per token than the previous generationtext-embedding-ada-002and scores higher on the MTEB benchmark (see OpenAI's announcement).To preserve previous behavior, pass
model="text-embedding-ada-002"explicitly (or, for the Azure variants,azure_deployment="text-embedding-ada-002").Note for Azure users:
azure_deploymentrefers to the deployment name in your Azure OpenAI resource, not a model identifier. If you do not already have atext-embedding-3-smalldeployment in Azure, you will need to create one or pass the name of an existing deployment explicitly.Embeddings generated with the new default model are not compatible with embeddings produced by
text-embedding-ada-002. If you have a document store populated with previously-generated embeddings, either keep using the old model explicitly or re-embed your corpus. -
The base
Tool.warm_up()andToolset.warm_up()methods were removed. OpenAI/Azure Chat Generators no longer warm up tools.If you use tools through an Agent, no changes are needed. For manual preparation, use
warm_up_toolsorwarm_up_tools_async. Lazy toolsets such asMCPToolsetmust be warmed up before passing them to a Chat Generator.
🚀 New Features
-
Added the experimental
ToolResultOffloadCompactorforCompactionHook. Once compaction is triggered, it writes older tool results to aToolResultStoreand leaves a reference in their place, so the Agent can read the full output back with a read tool you scope to the same store. Fresh results stay in context until then.from typing import Annotated from haystack.components.agents import Agent from haystack.components.generators.chat import OpenAIResponsesChatGenerator from haystack.hooks.compaction import CompactionHook, ToolResultOffloadCompactor from haystack.hooks.tool_result_offloading import FileSystemToolResultStore from haystack.tools import tool store = FileSystemToolResultStore(root="tool_results") @tool def read_offloaded_result(path: Annotated[str, "Path of an offloaded tool result"]) -> str: """Read back the full content of an offloaded tool result.""" content = store.read(path) if isinstance(content, bytes): return f"'{path}' holds {len(content)} bytes of binary content and cannot be read as text." return content # web_search stands in for your own tool that returns long results. hook = CompactionHook(compactor=ToolResultOffloadCompactor(store=store), context_window=400_000) agent = Agent( chat_generator=OpenAIResponsesChatGenerator(model="gpt-5.4-nano"), tools=[web_search, read_offloaded_result], hooks={"before_llm": [hook]}, )
⚡️ Enhancement Notes
-
When an
Agentruns inside aPipeline, the anonymous pipeline telemetry event now also reports the chat generator class and its tools: the number of tools, the class and name of each tool, the component wrapped by eachComponentTool, and the classes of anyToolsetobjects. -
Haystack now officially supports Python 3.15.
-
Removed the
Sockets.__getattribute__override. It looked up a_socketsattribute that no instance has, so on every attribute access it raised and caughtAttributeErrorand never returned a socket; socket access has always gone through the copies__init__and__setitem__put in__dict__. Without the override, accessing a socket attribute such ascomponent.__haystack_input__.queryis about 14x faster. -
HTMLToDocumentnow uses Trafilatura's encoding detection instead of assuming UTF-8, allowing non-UTF-8 sources to be converted without manually specifying their encoding. If conversion produces incorrect characters, installtrafilatura[all]for additional encoding detection support, or set the converter'sencodingexplicitly. -
LLMEvaluatorfallback warnings now name the component class, for exampleFaithfulnessEvaluator, and say that the result for a failed input is set toNone. A response that isn't valid JSON or is missing the expected keys now gets its own warning with the response and the parsing error. -
LLMRankerfallback warnings for an empty query, a chat generation failure and a response parsing failure now consistently say the deduplicated input documents are returned unranked, so the fallback order isn't mistaken for a ranking. -
QueryExpanderfallback warnings now say what is returned: only the original query, or no queries wheninclude_original_queryisFalse.QueryExpandernow also warns when the ChatGenerator response isn't valid JSON, including the response and the parsing error, and when the response contains no valid queries. -
When no
chat_generatoris passed,LLMRankerandQueryExpandernow useOpenAIResponsesChatGeneratorinstead ofOpenAIChatGenerator, with the same model and temperature. The default generator sets"store": Falseso OpenAI does not keep the requests and responses. -
Agent now uses its
close()/close_async()methods to release its configured tools and supports asynchronous tool preparation usingwarm_up_async(). Invoking a tool directly throughtool.invoke()ortool.invoke_async()automatically calls its warm-up methods when available.This enables tools to manage synchronous and asynchronous resources separately. We'll update tools in Haystack Core Integrations to take advantage of this support.
-
When a tool call made by
Agentfails, itshaystack.agent.step.tooltracing span now carries the taghaystack.tool.errorset toTrue. Tracers can use it to mark the span as failed, so a failed call is no longer indistinguishable from a tool that returns an{"error": ...}dict. The tag is set even when content tracing is disabled.
🐛 Bug Fixes
- Fixed
DOCXToDocumentdropping the fragment of an external link when Word stores it separately in thew:anchorattribute instead of the hyperlink address. For example, a link tohttps://example.org/manual#refund-policywas converted tohttps://example.org/manualwith both themarkdownand theplainlink_formatoption. The complete link target, including the fragment, is now preserved. - Fixed
RecursiveDocumentSplitterassigningpage_numbervalues that drifted upwards, and could exceed the number of pages in the document, whensplit_overlapwas greater than 0. A page break repeated in an overlapping tail was counted once per chunk it appeared in.page_numberis now derived from the chunk's offset in the source document instead of a running counter, so a repeated page break cannot be counted twice. - Fixed
RecursiveDocumentSplitterreportingsplit_idx_startvalues past the chunk's real start whensplit_unit="word"andsplit_overlapwas greater than 0, which also pushedpage_numberonto pages the chunk had not reached yet.split_idx_startnow points at the chunk's start in the source document, and the_split_overlapranges cover exactly the text the two chunks share. - Fixed
RecursiveDocumentSplitterdropping the whitespace after the last sentence of each piece of text it split into sentences, such as the"\n\n"ending a paragraph longer thansplit_length. This lost text, and moved thesplit_idx_startof every later chunk before its real start, which could put a chunk on the page before the one it starts on. The whitespace is now kept at the end of the chunk it follows, so with no overlap the chunks add up to the document again. Such a chunk can exceedsplit_lengthby that trailing whitespace. bound_event_dict_valuesnow also bounds log values nested insidelist,tupleanddictfields. Exceptions in a container are rendered withstrinstead ofrepr, so aUnicodeDecodeErrorno longer writes the whole buffer it failed to decode into the log line, and containers are cut short in place with[container budget exhausted],[recursive container]or[maximum depth reached]markers when they reach the traversal limits.- Converting PDF pages to images, as
PDFToImageContentandDocumentToImageContentdo, now closes the pypdfium2 document even when rendering a page raises an error. Before, the document was only closed after all pages rendered successfully, so a failing page left it open until garbage collection. DOCXToDocumentnow extracts text and tables inside text boxes. Previously this content was silently dropped.- With Python 3.15 lazy imports,
from haystack.tools import toolnow returns thetooldecorator. Before,lazy from haystack.tools import tooland imports underPYTHON_LAZY_IMPORTS=allreturned thehaystack.tools.toolmodule instead. AnswerExactMatchEvaluator,DocumentMAPEvaluator,DocumentMRREvaluatorandDocumentRecallEvaluatornow raise a descriptiveValueErrorwhen called with empty input lists, instead of failing with an opaqueZeroDivisionError. This matches the existing behavior ofDocumentNDCGEvaluator.- Fixed
FilterPolicy.MERGEreturning documents that the initialization filters exclude. When the initialization and runtime filters were both logical filters sharing anORorNOToperator, their conditions were concatenated, which unions the two filters rather than applying both. They are now combined under anANDso each filter still restricts the result. Merging twoANDfilters is unchanged. SentenceSplitternow reads its curated abbreviation lists as UTF-8 regardless of the system locale. Previously, on Windows, the German list was decoded with the ANSI code page (for example cp1252), so abbreviations containing umlauts orß, such asggü.oraußenpol., were garbled and did not prevent sentence splits whenextend_abbreviations=True.- Fixed a
SchemaGenerationErrorwhen using@tool,create_tool_from_function, orComponentToolwith a parameter typed asAnnotated[State, ...], including optional variants. These parameters are now excluded from the schema sent to the language model and receive the Agent's liveStateobject when the tool runs, just like parameters typed asState. InMemoryDocumentStoreno longer counts Documents withoutcontent, such as image Documents that only have an embedding, in its BM25 statistics. Previously they increased the document count and lowered the average document length, which changed the BM25 scores and ranking of text Documents even though BM25 retrieval never returns Documents without content.ChatMessage.from_openai_dict_formatnow converts messages whosecontentis a list of content parts. Previously, the whole list was stored in a singleTextContent, soChatMessage.textreturned a list instead of a string. In user messages,textparts now becomeTextContent,image_urlparts with base64 data URLs becomeImageContent, andfileparts with inlinefile_databecomeFileContent. In system, developer, and assistant messages,textparts are joined with a newline into a single text. An assistant's refusal, given as arefusalpart or as therefusalfield, is stored inChatMessage.meta["refusal"]instead of the text, andChatMessage.to_openai_dict_formatsends it back.contentthat is neither a string nor a list now raises aValueErrorinstead of being stored as text.EvaluationRunResult.aggregated_report,detailed_reportandcomparative_detailed_reportwithoutput_format="csv"now write the CSV file as UTF-8. Previously the file used the locale encoding, so on Windows a report with text outside the ANSI code page (e.g. Japanese or Cyrillic questions) was left half-written and an error message was returned instead, and accented characters were written as cp1252.- Fixed
expand_page_rangesilently dropping pages when a reversed range (e.g."7-5") appeared alongside valid entries. Mixed inputs like["1-3", "7-5", "8"]previously returned[1, 2, 3, 8]with no warning, losing pages 5-7. A reversed range now raisesValueErrorwith a descriptive message identifying the offending range. logger.exception()on loggers fromhaystack.logging.getLoggernow logs the active traceback, as the standard library does. Previously it logged only the message, without the stack trace, in both JSON and console output. Passingexc_infoexplicitly still works.MetaFieldRankernow appliesmissing_metawhen the meta values can't be sorted together, for example1and"x". In this case the Documents keep their original order, but Documents missing the meta field are dropped with"drop", placed first with"top", and placed last with"bottom". Previously, all Documents were returned in their original order andmissing_metawas ignored.OpenAIChatGeneratorandOpenAIResponsesChatGeneratorno longer drop a tool call whose arguments are an empty string, and no longer raiseTypeErrorwhen they arenull, in non-streaming responses. OpenAI-compatible servers such as vLLM send these for a tool with no parameters. Both now become empty arguments, as they already did in streaming responses and inChatMessage.from_openai_dict_format.- The default prompt of
QueryExpandernow states that its examples show the output for 3 requested queries and that exactlyn_expansionsqueries must be returned. The trailing JSON format example, which always showed three queries, is removed. Before, the examples showed three or four queries without saying so, which contradicted the instruction for any othern_expansionsvalue. - Fixed
SentenceWindowRetrieverreturningcontext_windowswith the chunks in the wrong order when the documents have nosplit_idx_startmeta field, for example chunks produced byMarkdownHeaderSplitter. The chunks were concatenated in the order returned by the Document Store instead of bysplit_id, so the context passed to an LLM could be scrambled whilecontext_documentswas correctly sorted. The chunks are now sorted bysplit_idbefore being merged, in bothrunandrun_async. JsonSchemaValidator.run()now raises aValueErrorsaying the message list is empty when it receives no messages, instead of anIndexErrorfrom reading the last message.ToolResultOffloadHookandToolResultOffloadCompactorno longer stop the Agent run when aToolResultStorewrite fails, for example whenFileSystemToolResultStorerejects a key containing..or the disk is full. The result stays in the conversation, a warning naming the tool and the store is logged, and the remaining results are still offloaded. If one part of a result with several content blocks fails to write, the whole result stays in the conversation.- Fixed misleading
LLMDocumentContentExtractormessages. The failure warning said "Skipping metadata extraction", and a document that couldn't be converted to an image gotextraction_errorset to "Document has no content". The docstring also named the wrong metadata field (content_extraction_errorinstead ofextraction_error). Warnings now say the document is returned infailed_documents, and a response that isn't a JSON object is now logged too. - Fixed the
LLMEvaluatorwarning that summarizes failed inputs. It logged the raw templateLLM evaluator failed for {errors} out of {len(list_of_input_names_to_values)} inputs.instead of the counts. This also affectsFaithfulnessEvaluatorandContextRelevanceEvaluator. LostInTheMiddleRankernow validates all input documents before deduplication andtop_kselection, raisingValueErrorif any document is non-textual. Previously, single-document selections and documents excluded bytop_kcould bypass the text-only validation.- Fix
Pipeline.connect()silently skipping a new socket-to-socket connection when other connections already link the same sender and receiver components. Repeating an existing connection remains a no-op, and invalid additional connections now raise the expected error. - When a pipeline fails and its snapshot can't be written to disk,
error.pipeline_snapshot_file_pathis nowNone. Before, it held the path of the snapshot file that was never written. CSVToDocumentin row mode now preserves values from a CSV column namedrow_numberascsv_row_numberin metadata (with a suffix on collision), instead of overwriting them with the generated row index.- Redact large base64-encoded binary payloads (
ImageContentandFileContent) nested insideToolCallResultwhen tracing is enabled. Replaces raw base64 data with placeholder strings (such as"Base64 string (N characters)"), preventing multi-megabyte payloads from leaking into pipeline tracing spans. - Raise
FilterErrorinstead of leaking a PythonTypeErrorwhen an ordering metadata filter compares adatetimewith a non-datetime value. OpenAIResponsesChatGeneratorandAzureOpenAIResponsesChatGeneratornow map the raw reasoning text that the Responses API returns asreasoning_textcontent toReasoningContent.reasoning_text, both when streaming (response.reasoning_text.deltaevents) and when not streaming. Previously, this text was dropped while streaming and only kept inReasoningContent.extra["content"]otherwise. OpenAI models only return reasoning summaries, which still take precedence, but other providers such as OpenRouter return the raw reasoning. When such a reply is sent back to the model in a later turn, the raw reasoning is no longer duplicated as a summary.
💙 Big thank you to everyone who contributed to this release!
@Aditya17-bot, @agnieszka-m, @amengnew, @anakin87, @ArzelaAscoIi, @Ayushdevo, @basilalshukaili, @Ben160804, @bhaskargurram-ai, @bilgeyucel, @bogdankostic, @camgrimsec, @chrikrah, @davidsbatista, @Evihut, @hardness1020, @Harsh23Kashyap, @julian-risch, @kacperlukawski, @L4XB, @LimbC-C, @LindseyZ1205, @Marcuswang0824, @Morningstar202604, @otiscuilei, @rautaditya2606, @rxchitrx, @ShousenZHANG, @sjrl, @Timotty, @TimurRakhmatullin86, @Vedant-Agarwal, @yuv009