github deepset-ai/haystack v3.1.0-rc1

pre-release4 hours ago

Release Notes

v3.1.0-rc1

Upgrade Notes

  • exit_reason is now a reserved state key on Agent. If you defined a custom state_schema key named exit_reason, rename it: the Agent now raises a ValueError at initialization when a reserved key is redefined.

  • Agent.state_schema now contains the user-provided state schema, exactly as passed to __init__. Previously it contained the resolved schema, which also included messages and the keys the Agent manages internally (step_count, token_usage, exit_reason, ...). If you were reading agent.state_schema to inspect the effective runtime schema, use the new public attribute agent.resolved_state_schema instead.

  • DocumentMAPEvaluator scores can change because average precision now uses all unique, valid ground-truth comparison values as its denominator and credits each value at most once. Re-baseline evaluations that relied on the previous scores.

  • PipelineSnapshot.pipeline_state.inputs changed shape. It used to store one flattened value per socket, {component: {socket: value}}. It now stores the pipeline's internal inputs, keeping the component that sent each one in the order it arrived: {component: {socket: [{"sender": ..., "value": ...}]}}. PipelineState gained an inputs_format field recording which of the two shapes a snapshot uses. The same applies to BreakpointException.inputs, which returns that field.

    You are affected if you read pipeline_state.inputs (or BreakpointException.inputs) directly, for example to display or post-process a snapshot. Resuming a pipeline with Pipeline.run(pipeline_snapshot=...) is not affected, and neither is code that only passes snapshots around or persists them.

    To adapt, read the input from the list and take its value:

    inputs = snapshot.pipeline_state.inputs["serialized_data"]
    
    # before
    value = inputs["my_component"]["my_socket"]
    
    # now
    value = inputs["my_component"]["my_socket"][0]["value"]

    A socket that received inputs from several senders has one list item per input, each recording the sender that produced it.

    Snapshots written by earlier versions of Haystack have inputs_format set to None and keep the flattened shape, so branch on that field if you need to handle both.

  • Loading a serialized OutputAdapter or ConditionalRouter whose unsafe init parameter is set to true now raises DeserializationError unless the pipeline is loaded in unsafe mode. Pipelines that legitimately rely on an unsafe OutputAdapter/ConditionalRouter embedded in serialized data must now load with Pipeline.load(..., unsafe=True) (or Pipeline.loads / Pipeline.from_dict with unsafe=True).

  • Passing window_size=0 to SentenceWindowRetriever.run or SentenceWindowRetriever.run_async now raises a ValueError instead of silently using the window_size set in the constructor. You are affected if you pass window_size=0 at runtime, either directly or from an upstream component in a pipeline. If you were relying on 0 to mean "use the value from the constructor", omit the argument (or pass None) instead:

    retriever = SentenceWindowRetriever(document_store=document_store, window_size=3)
    
    # Before: silently used window_size=3
    retriever.run(retrieved_documents=docs, window_size=0)
    
    # After: omit the argument to use the constructor value
    retriever.run(retrieved_documents=docs)
  • InMemoryDocumentStore.get_metadata_field_unique_values (and its async counterpart)'s search_term parameter now matches against the metadata field's own value (case-insensitive substring) instead of the document's content. Callers relying on the previous content-matching behavior will need to filter documents by content themselves before calling this method.

  • The Agent now warms up its hooks before every run, not only the first one, as it already does for Tools and Toolsets. If your hook has a warm_up() that does expensive setup (opening a client, loading a model), make it return early once done, for example if self._client is not None: return.

  • Haystack can call warm_up() on Tools and Toolsets more than once, for example before every run. Previously Toolset absorbed repeated calls with an internal _is_warmed_up flag; that flag is gone and every call now reaches your warm_up(). If your custom Tool or Toolset does expensive work there (connecting to a server, loading a model), or relied on the _is_warmed_up attribute, guard with your own state and return early, for example if self._client is not None: return.

New Features

  • Added a link_format parameter to both PyPDFToDocument and PDFMinerToDocument components, matching the existing functionality in DOCXToDocument. Links are parsed from PDF annotations and appended at the bottom of the page content.

  • Added experimental context compaction for the Agent. CompactionHook runs before LLM calls and shortens the conversation when it reaches a configured fraction of the model's context window.

    The first built-in strategy, SlidingWindowCompactor, preserves leading system messages, the latest user task, and as much complete recent conversation as the target allows. It removes complete historical turns first, and only when removing every historical turn is insufficient does it remove individual Agent steps from the current task. It replaces removed history with a short omission note, left where the removed messages used to sit: directly after the leading system messages when only historical turns were removed, and directly after the latest user message when the current task's own steps were removed. Only one note is ever present, because a later compaction folds an earlier one into its replacement.

    from haystack.components.agents import Agent
    from haystack.components.generators.chat import OpenAIResponsesChatGenerator
    from haystack.hooks.compaction import CompactionHook, SlidingWindowCompactor
    
    hook = CompactionHook(
        compactor=SlidingWindowCompactor(),
        context_window=400_000,
        compact_at=0.7,
        compact_to=0.4,
    )
    agent = Agent(
        chat_generator=OpenAIResponsesChatGenerator(model="gpt-5.4-nano"),
        tools=[web_search],
        hooks={"before_llm": [hook]},
    )

    The hook uses provider-reported context usage when available and locally estimates the request otherwise, including tool schemas. Leave headroom above compact_at for the next reply and its tool results.

    SlidingWindowCompactor treats an assistant message and its following tool results as one step, so a tool call is never separated from its results. Historical turns are likewise kept or removed in full, so an assistant reply is not retained without the user message it answers. It can also land above the requested target rather than under it, because leading system messages and the current task are never removed and min_keep_steps holds on to the newest Agent steps whatever their size, so a long system prompt or one large tool result can leave the conversation well over the target. Compaction is lossy: removed messages cannot be recovered or summarized by this strategy. Implement the Compactor protocol to provide a custom strategy.

    CompactionHook and SlidingWindowCompactor emit an ExperimentalWarning and may change without a deprecation cycle.

  • Agent now returns an exit_reason output reporting why the run stopped, making it easier to route the Agent's output downstream (for example with a ConditionalRouter). It is one of: "text" (the model returned a reply with no tool calls), the name of the tool that satisfied a tool exit condition (in which case last_message is that tool's result), or "max_agent_steps" (the Agent hit max_agent_steps before meeting an exit condition). The reason is also available to hooks via state.get("exit_reason"), so an after_run hook can, for instance, append a fallback answer when the step budget is exhausted.

  • Add OpenAITokenCounter, which uses OpenAI's input token counting API to return model-specific counts for Haystack ChatMessage objects and optional tool schemas. Unlike local estimates, it supports OpenAI's exact accounting for request formatting, images, files, and tools.

    Here is an example:

    from haystack.dataclasses import ChatMessage
    from haystack.token_counters import OpenAITokenCounter
    
    counter = OpenAITokenCounter("gpt-5-mini")
    count = counter.count([ChatMessage.from_user("Hello!")])
  • Added haystack.token_counters: a TokenCounter protocol for estimating how many tokens a list of ChatMessage objects occupies, with two implementations.

    Providers report token usage only after a call, and only for the call as a whole, so anything that needs a size beforehand - deciding whether a conversation still fits a model's context window, or how much of it to drop - has to estimate one.

    from haystack.dataclasses import ChatMessage
    from haystack.token_counters import ApproximateTokenCounter, TiktokenCounter
    
    messages = [ChatMessage.from_user("Hello, how are you?")]
    
    # No dependencies: estimates from text length.
    ApproximateTokenCounter(chars_per_token=4.0).count(messages)
    
    # Closer for OpenAI models; needs: pip install tiktoken
    TiktokenCounter(encoding="o200k_base").count(messages)

    ApproximateTokenCounter needs nothing installed and estimates from text length at a configurable chars_per_token. TiktokenCounter counts with OpenAI's byte-pair encoder, which is closer for OpenAI models but requires tiktoken and drifts on other providers; it raises at construction when the dependency is missing, and loads its encoding on first use.

    Neither can measure an image or a file, since a tokenizer only sees text and providers derive an image's cost from its dimensions. Both charge a flat tokens_per_image and tokens_per_file instead, counting images a tool returned inside its result as well as those a message carries directly. Raise those values if you send large images or long documents.

    Tool schemas are sent alongside the messages and consume tokens too, so count takes an optional tools argument to have them included:

    counter.count(messages, tools=[my_tool])

    Implement TokenCounter to count differently - for instance against a provider's own token-counting endpoint, which is the only way to have images counted exactly.

  • Added the experimental ToolResultPruningCompactor. It reduces Agent context usage by replacing older, large tool results with short placeholders while preserving tool-call/result structure. Results from a configurable number of recent tool-calling Agent steps remain intact, including parallel results from those steps.

    from haystack.hooks.compaction import CompactionHook, ToolResultPruningCompactor
    
    compaction_hook = CompactionHook(
        compactor=ToolResultPruningCompactor(
            min_keep_steps=2,
            min_tokens=200,
        ),
        context_window=400_000,
        compact_at=0.7,
        compact_to=0.4,
    )
  • Add an Agent.clone() method that returns a new Agent with the same configuration, optionally replacing some init parameters: variant = agent.clone(system_prompt="Answer in German.").

  • Added AgentTool, a Tool that wraps a Haystack Agent, allowing it to be used as a tool by another Agent. It is a building block for multi-agent systems: an Agent specialized in one task becomes a tool that another Agent can delegate to. The calling Agent only sees the final reply, so all the steps the wrapped Agent takes stay out of its context. Sensible defaults make this work out of the box: the task is delegated as a single user message and comes back as text.

    Example:

    from haystack.components.agents import Agent
    from haystack.components.generators.chat import OpenAIResponsesChatGenerator
    from haystack.dataclasses import ChatMessage
    from haystack.tools import AgentTool, ComponentTool
    from haystack_integrations.components.websearch.serperdev import SerperDevWebSearch
    
    researcher = Agent(
        chat_generator=OpenAIResponsesChatGenerator(model="gpt-5.4-mini"),
        system_prompt="You are a research specialist. Investigate the task and report your findings.",
        tools=[
            ComponentTool(
                component=SerperDevWebSearch(
                    top_k=3,
                ),
                name="web_search",
                description="Search the web for current information on any topic",
            ),
        ],
    )
    
    research = AgentTool(
        agent=researcher,
        name="research",
        description="Research a question on the web and report the findings",
    )
    
    coordinator = Agent(
        chat_generator=OpenAIResponsesChatGenerator(model="gpt-5.4"),
        tools=[research],
        system_prompt="You coordinate specialists. Delegate research questions, then answer the user.",
    )
    
    result = coordinator.run([ChatMessage.from_user("What are the latest developments in the Haystack framework?")])
    print(result["last_message"].text)
  • Haystack components that use a document store now provide close and close_async methods for releasing resources. These methods are available on: AutoMergingRetriever, CacheChecker, DocumentWriter, FilterRetriever, and SentenceWindowRetriever. If the underlying Document Store does not implement the corresponding method, calling close or close_async has no effect.

  • Add a content-free haystack.agent.hook tracing span for every Agent hook invocation. Each span identifies the hook point, hook name, and hook type, allowing hook latency and failures to be attributed without tracing the potentially large Agent State. CompactionHook also adds its configured compaction strategy, estimated context size, whether compaction was triggered, its token target, and whether the compactor returned a replacement.

Enhancement Notes

  • Extracted DOCXLinkFormat to a reusable LinkFormat Enum in haystack/components/converters/utils.py. DOCXLinkFormat is now an alias for backward compatibility.
  • The Agent now tracks an approximate current context-window size in its internal State under context_tokens, refreshed after every LLM call with that reply's prompt-plus-completion tokens (normalized across the prompt_tokens/completion_tokens and input_tokens/output_tokens key conventions). Unlike token_usage, which accumulates across the whole run, context_tokens is replaced each call. Hooks can read it via state.get("context_tokens") — for example, a before_llm hook that triggers context compaction once the value crosses a threshold. It is a best-effort snapshot: it is 0 when the generator does not report usage, and does not count messages appended after the latest call until the next call refreshes it.
  • before_run hooks can now read state.data["tools"]. The key was previously written only once the first step had started, so a before_run hook hit a KeyError. It holds a snapshot of the tools available at that point, refreshed before every LLM call, so with a dynamic toolset such as SearchableToolset a before_run hook sees only the tools discovered so far.
  • Added an opt-in strict_datetime_comparison keyword argument to document_matches_filter, InMemoryDocumentStore, and MetadataRouter. When enabled, timezone-naive and timezone-aware datetimes never match each other. By default, mixed-awareness datetimes continue to be reconciled by copying the timezone from the aware value to the naive one, and this behavior is now consistent across equality, membership, and ordering operators.
  • Added a filters parameter to InMemoryDocumentStore.get_metadata_field_unique_values (sync and async), allowing the set of documents considered when computing unique metadata field values to be restricted.
  • InMemoryDocumentStore.get_metadata_field_unique_values and its async counterpart now support pagination via from_ and size parameters, matching the behavior of other Document Stores (e.g. Chroma).
  • MockChatGenerator's response_fn can now be tool-aware. If the callable accepts a second positional argument, it also receives the tools passed to run/run_async (a ToolsType or None), so a dynamic mock can build tool calls whose arguments follow the tool's parameter schema or route between the available tools. Existing single-argument response_fn callables are unaffected and keep receiving only the messages.
  • State.to_dict now accepts a skip_keys parameter to exclude specific keys from the output.
  • Add a tracing span per ConfirmationStrategy run to ConfirmationHook. Each haystack.agent.hook.human_in_the_loop.strategy span identifies the tool call it confirms and records the strategy type and the applied confirm, modify, or reject decision. When content tracing is enabled, spans also carry the arguments the strategy was run with and the ToolExecutionDecision it returned under haystack.agent.hook.human_in_the_loop.strategy.input and haystack.agent.hook.human_in_the_loop.strategy.output; chat messages, the confirmation strategy context, and the Agent State are not recorded by the hook.
  • LLMEvaluator, LLMRanker, QueryExpander, LLMMetadataExtractor, LLMDocumentContentExtractor and LLMMessagesRouter now wrap their internal ChatGenerator calls in a haystack.chat_generator.run tracing span. These components do not return ChatMessage objects, so the LLM token usage carried in reply.meta["usage"] was previously lost to tracers. The new span exposes the generator's replies via the haystack.component.output tag, so token usage is now visible in traces (requires content tracing to be enabled). When the generator runs across threads, the span is nested under the component's span.

Deprecation Notes

  • Two ways of combining Toolsets are deprecated and will be removed in Haystack 3.2.0: the + operator (toolset_a + toolset_b) and passing a Toolset to add() (toolset_a.add(toolset_b)). Pass Toolsets as a list wherever tools are accepted instead: Agent(tools=[toolset_a, toolset_b]).

Security Notes

  • Fixed a remote code execution vulnerability that could be triggered by loading an untrusted pipeline in default safe mode (Pipeline.load / Pipeline.loads / Pipeline.from_dict, without unsafe=True). A malicious pipeline could either (a) set unsafe: true on an OutputAdapter or ConditionalRouter to disable the Jinja sandbox entirely, or (b) register the thread_safe_import import primitive as a Jinja custom_filters entry to import os and execute arbitrary commands — bypassing the deserialization allowlist and Jinja sandbox. The fix denies import primitives during callable deserialization, refuses to honor a component's unsafe flag while loading in safe mode, and hardens the Jinja sandbox (OutputAdapter, ConditionalRouter, PromptBuilder, ChatPromptBuilder) to block attribute access on module objects and calls into dangerous modules.
  • Closed an additional remote code execution vector in the deserialization control-plane hardening: the pipeline loading entry points (Pipeline.loads / Pipeline.load / Pipeline.from_dict, which accept unsafe=True) and the execute primitives (Pipeline.run / run_async / run_async_generator / stream) were still resolvable from the allowlisted haystack namespace. Bound as a custom_filters entry on an OutputAdapter or ConditionalRouter (which bypass the Jinja sandbox), Pipeline.loads(..., unsafe=True) let a pipeline loaded in default safe mode load a nested pipeline whose own filters (allow_deserialization_module, deserialize_callable) bind under the nested unsafe context, disarming the process-wide allowlist with "*" and invoking os.system. All of these entry points are now marked as deserializer-internal, so they can never be produced by deserializing untrusted data. unsafe=True still bypasses the check by design; there are no public API changes.
  • Fixed a remote code execution vulnerability that could be triggered by loading an untrusted pipeline in default safe mode (Pipeline.load / Pipeline.loads / Pipeline.from_dict, without unsafe=True). Because the deserialization allowlist admits the whole haystack namespace, the deserializer's own allowlist-administration function (allow_deserialization_module) and its resolution helpers (deserialize_callable, deserialize_type, import_class_by_name) were themselves resolvable from serialized data. A malicious pipeline could register allow_deserialization_module as a Jinja custom_filters entry (on an OutputAdapter or ConditionalRouter), call it with "*" to disarm the allowlist process-wide, and then use the equally-resolvable deserialize_callable to resolve and invoke os.system. Loading alone was enough to trigger this: a Jinja filter called with constant arguments runs while the component is being constructed, so the pipeline never had to be run. The same attribute walk could also reach the deserializer's mutable control-plane state directly — for example a filter bound to _extra_allowed_modules.append — to widen the allowlist persistently and stage a later attack. Relatedly, the handle resolver walked attribute names freely, so a handle could descend into object internals such as <function>.__globals__ (a live module namespace, and via it __builtins__ and eval/exec) or <type>.__subclasses__ — classic sandbox-escape gadgets that stay inside an allowlisted module. The fix refuses to deserialize the deserialization control plane as a whole: the allowlist administration and resolution helpers (marked at definition time), everything defined in haystack.core.serialization_security, and any bound method of the mutable allowlist/context state. It also refuses to traverse into dunder and frame/code attributes while resolving a handle. This applies to both the callable- and class-resolution paths, and is bypassed only when the pipeline is loaded with unsafe=True.
  • Harden FileSystemToolResultStore.read() so it only reads references that resolve within the configured store root. This closes a boundary gap where callers could previously pass an arbitrary filesystem path to read() instead of a store-scoped reference returned by write().

Bug Fixes

  • Fixed the serialization of PDFMinerToDocument. The component did not define to_dict, so the default serialization fell back to reading the init parameters from same-named attributes. Since the layout parameters are stored in self.layout_params, they were silently serialized with their default values, for example a component created with char_margin=0.5 was serialized with char_margin=2.0. Custom layout parameters are now preserved when a pipeline is serialized and loaded again.

  • Cancel and await sibling retrieval tasks when a concurrent call fails in MultiRetriever, MultiQueryTextRetriever, or MultiQueryEmbeddingRetriever.

  • Fixed an infinite recursion in CSVDocumentSplitter when nested row and column blocks were split together.

  • Comparing two Document objects with == now takes all metadata into account. Previously, two documents with different metadata could be considered equal if the metadata contained keys with the same names as document fields (such as id or content).

  • Document.from_dict(document.to_dict()) now correctly rebuilds any document. Previously, if the metadata contained keys with the same names as document fields (such as id or meta), this either raised an error or silently lost those metadata entries.

  • Fixed DocumentNDCGEvaluator producing NDCG scores outside the documented 0.0 to 1.0 range when the same document appeared more than once. A document retrieved multiple times used to be counted multiple times, pushing the score above 1.0; a ground truth document listed multiple times used to inflate the ideal gain, keeping a perfect retrieval below 1.0. Each distinct relevant document is now counted once, with the same relevance, in both the actual and ideal gain, so scores stay within range.

  • Fixed AnswerBuilder returning referenced documents in a scrambled order instead of ascending source-index order. The referenced document indices were collected in a set and iterated directly, so documents were emitted in the set's internal hash-table order (e.g. citations [3] [10] [50] yielded documents ordered 10, 3, 50). This order was deterministic but did not match the intuitive source order. The referenced documents are now returned sorted by their source index.

  • Fixed serialize_type and deserialize_type to correctly round-trip Callable types that declare an explicit parameter list, such as Callable[[int, str], bool]. Previously the parameter list was dropped during serialization (producing a malformed string like typing.Callable[, bool]) and could no longer be deserialized. This affected components that serialize type annotations, for example ConditionalRouter and OutputAdapter using a Callable output type.

  • Fix DocumentMAPEvaluator to include missed relevant documents in the average precision denominator and avoid crediting duplicate retrievals of the same document.

  • Fixed DocumentSplitter producing chunks that were not present in the source document when split_threshold was set together with split_overlap. Merging a below-threshold trailing segment into the previous split re-appended the overlapping units, duplicating text. The overlap is now added only once.

  • Fixed EmbeddingBasedDocumentSplitter.run_async embedding through the synchronous path while recursively splitting chunks longer than max_length. Only the first pass was async: the recursion called the sync splitting helper, so the embedder's blocking run ran on the event loop for every over-long chunk. The recursion now embeds through run_async as well.

  • Fixed JSONConverter raising a KeyError instead of logging its intended "Failed to extract text, skipping it" warning when a source is a ByteStream without a file_path in its meta (for example ByteStream.from_string(...), the exact usage shown in the component's own docstring examples). Affected error paths: invalid UTF-8 content, a jq_schema filter that fails to apply, and malformed JSON content.

  • Fixed LinkContentFetcher rotating the User-Agent on a cursor shared by every URL in the same run()/run_async() call. The URLs are fetched concurrently, so a retry triggered by one of them advanced the user agent for the others, and each completed fetch reset the cursor for the requests still in flight — most retries went out with the un-rotated user agent. Each fetch now walks the user_agents list on its own, so a URL rotates exactly as documented no matter how many other URLs are fetched alongside it.

  • Fixed MarkdownHeaderSplitter silently dropping a trailing header that has no body text. With keep_headers=True (the default), a header at the end of the document whose only content is whitespace was buffered to prepend to the next chunk, but with no following chunk it was never emitted, so the split documents no longer reconstructed the original text. Such trailing headers are now emitted as a final chunk.

  • Fixed MarkdownHeaderSplitter collapsing blank lines that follow a header with no body text. With keep_headers=True, such headers were re-joined with a single newline when prepended to the next chunk, so the split documents did not reconstruct the original text. Chunk content is now sliced from the original text and is byte-exact.

  • Fixed MarkdownHeaderSplitter including surrounding whitespace in the header and parent_headers metadata fields. The header text is now stripped; chunk content still keeps the header line's original whitespace.

  • Fixed schema-based serialization of lists, tuples and sets holding mixed types. Previously the schema was derived from the first element only, so deserializing such a value raised an AttributeError or silently returned mis-typed data (for example an Agent State field or a pipeline breakpoint input holding [Document(...), "text", 3]). Mixed-type arrays now record one schema per position using the JSON Schema prefixItems keyword and round-trip correctly. Homogeneous arrays keep the exact same output as before, so existing snapshots still load.

  • Fixed MSGToDocument raising a KeyError when converting a ByteStream source that has no file_path in its meta (for example a bare ByteStream(data=...), rather than a file path or a stream produced via ByteStream.from_file_path). Attachments extracted from such a source no longer include a parent_file_path key, since there is no source file path to record.

  • Pipeline connections now always convert values in the same way. When a component output is connected to an input that accepts multiple types, Haystack is sometimes able to automatically convert the value, and more than one conversion may be possible. For example, a ChatMessage with text "hello" connected to an input annotated str | list[str] can be delivered either as plain text ("hello") or as a list containing the text (["hello"]). Previously the conversion strategy was chosen non-deterministically, so the same pipeline could return a different value across runs. The conversion strategy is now selected using a fixed priority: first, wrapping a value in a list or unwrapping a single-element list; second, converting between ChatMessage and str; and last, combining both conversions.

  • Fixed normalize_metadata (used by all file converters) returning the same dictionary object for every source when meta is None or a single dictionary. Each source now receives an independent copy, so mutating one source's metadata downstream no longer leaks into the others.

  • OpenAIResponsesChatGenerator no longer mutates the parameters schema of the Tool objects passed to it. Previously every run wrote additionalProperties: False into the user's live Tool.parameters, silently altering the tool for any other generator that shared the same Tool instance and making serialization round trips unstable.

  • OpenAIResponsesChatGenerator no longer raises IndexError when it is warmed up with an empty tools list.

  • Fixed the parent of the haystack.agent.step.tool spans when an Agent step invokes several tools. The parent span is now resolved once before the tools run, so all tool calls of a step appear as siblings. Previously each span asked the tracer for the currently active span from inside its own concurrent invocation, which made the tool calls after the first one appear nested under a sibling tool call.

  • Fixed the haystack.pipeline.output_data tracing tag being empty. The tag was set at the start of Pipeline.run/run_async from the still-empty outputs, and since tracing backends coerce a tag value when it is set, the recorded output was always an empty dictionary. It is now set once the run completes so it reflects the final pipeline outputs. The tag is also gated behind content tracing (HAYSTACK_CONTENT_TRACING_ENABLED), consistent with the component-level input/output tags.

  • Fixed resuming a Pipeline from a pipeline_snapshot that was taken on a component's second or later visit, which failed with PipelineComponentsBlockedError: Cannot run pipeline - all components are blocked. A snapshot stored only the values of the pipeline's inputs and dropped the information about which component had sent each one, so on resume every restored input looked like it came from outside the pipeline, and such an input can only trigger a component on its first visit. Snapshots now record the sender of each input. Snapshots created by earlier versions of Haystack behave as before, so re-create them to resume anywhere in a looping pipeline.

  • Fixed a resumed Pipeline passing malformed inputs to the component the snapshot was taken on, whenever that component ran more than once after the resume, for example inside a loop. Every visit after the first reused the handling meant only for the paused visit and skipped the regular input consumption, so a variadic component could receive a bare value where it expected a list, raising errors such as TypeError: object of type 'int' has no len() from a BranchJoiner. This affected snapshots taken at any visit count, including the first.

  • Fixed QueryExpander returning duplicate queries when the chat generator repeats an expansion. Generated queries are now deduplicated while preserving first-seen order, so repeated expansions no longer trigger redundant retrievals or consume the requested expansion budget. Both run and run_async are affected.

  • RecursiveDocumentSplitter's word-mode fixed-size fallback no longer counts a run of whitespace (e.g. a double space, tab, or page break) as a word, so it no longer produces chunks smaller than split_length. It also no longer emits a whitespace-only chunk when the text ends in whitespace right after a chunk boundary; that trailing whitespace is now attached to the previous chunk instead.

    This changes the exact chunk boundaries and chunk count produced by the word-unit fallback for any text containing such whitespace runs. Documents already split and indexed under the old behavior will produce different chunks if re-split after upgrading, so re-index any document store that relies on stable chunk boundaries from this fallback path.

  • Fixed an issue where PipelineBase.remove_component did not reset auto-variadic socket flags (is_lazy_variadic and wrap_input_in_list) on input sockets when components or connections were removed.

  • Fixed Pipeline.remove_component leaving dangling references to the removed component on the sockets of its neighboring components. Previously, removing a component reset only its own sockets, so a surviving neighbor kept the removed component's name in its input socket's senders (or output socket's receivers). This corrupted introspection and validation: Pipeline.inputs() hid a now-unconnected mandatory input, and feeding that input directly could raise a spurious "already connected" error. The removed component's name is now stripped from its neighbors' sockets as well.

  • SentenceWindowRetriever.run and SentenceWindowRetriever.run_async now validate an explicitly provided window_size=0 instead of treating it as unset and falling back to the constructor value.

  • Fixed the schema-aware serialization helper used for pipeline snapshots and Agent State (_serialize_value_with_schema) so it no longer silently passes unsupported objects through as if they were serialized. Values such as datetime, bytes, complex and arbitrary objects without a to_dict method were previously stored unchanged and mislabeled as strings, which broke JSON storage and round-tripping of snapshots. Unsupported values now raise a SerializationError, and the callers that build snapshots (pipeline breakpoints and State.to_dict) catch it to omit only the offending field while keeping the rest of the payload resumable.

  • Added support for serializing and deserializing frozenset values in _serialize_value_with_schema. A frozenset now round-trips back to a frozenset instead of being dropped.

  • Fixed serialize_type/deserialize_type for typing.Literal. Previously a Literal type hint was serialized with its values rendered as bare tokens (e.g. typing.Literal[yes, no]), which failed to deserialize, and values that looked like type names (e.g. Literal["int", "str"]) were silently turned into types on the round-trip. The values are now serialized with repr() and read back with ast.literal_eval, so a Literal type used by a component (such as OutputAdapter or ConditionalRouter) round-trips correctly through pipeline serialization.

  • Fixed an AttributeError: 'str' object has no attribute 'items' raised by create_tool_from_function, the @tool decorator, and ComponentTool when a tool parameter is named properties. Keys inside a JSON schema properties mapping are property names and are no longer misinterpreted as schema keywords when stripping the auto-generated title keywords.

  • Fixed create_tool_from_function, the @tool decorator, and ComponentTool corrupting a tool's JSON schema when the string title appears as a name rather than as a schema keyword. Stripping the auto-generated title keywords no longer deletes entries of $defs, definitions, patternProperties, dependentSchemas or dependentRequired (which would leave a $ref dangling or silently drop a validation rule), and no longer edits title keys inside default, const, enum or examples values, which are instance data and part of the tool's contract.

  • Fixed _ToolsetWrapper.__getitem__ (used when combining Toolsets with +) raising IndexError for negative indices, unlike a plain Toolset. Indexing a combined toolset now behaves consistently with a list of Tools, as documented.

  • Fixed serialization of types that contain ... (Ellipsis), such as variadic tuples (tuple[int, ...]) and Callable[..., X]. Previously serialize_type rendered the ... as the literal string "Ellipsis", which deserialize_type then rejected as a non-type builtin, so a component using such a type (for example OutputAdapter(output_type=tuple[int, ...])) could be serialized but not deserialized, breaking Pipeline.loads() / Pipeline.load(). These types now round-trip correctly, and pipelines serialized by older versions (which emitted "Ellipsis") can still be loaded.

  • Fixed ConfirmationHook applying a Human-in-the-Loop decision to the wrong tool call when a custom ConfirmationStrategy returns a decision with a missing or incorrect tool_call_id. Each decision is now bound to the tool call for which its strategy ran, and ID-bearing decisions are no longer matched to a different call by name. Haystack's existing requirement of exactly one decision per tool call is now explicitly enforced. Each matched decision is consumed after use, so a missing, unused, or reused decision raises a ValueError instead of being silently misapplied.

  • DocumentJoiner and AnswerJoiner now resolve top_k consistently and validate it. Previously, a runtime top_k=0 was treated as "unset" and silently fell back to the instance's top_k, instead of returning an empty list as requested. Both components now:

    • Raise a ValueError at initialization if top_k is not None and is less than or equal to 0.
    • Raise a ValueError at runtime if top_k passed to run() is negative.
    • Return an empty list when run() is called with top_k=0, regardless of the instance's configured top_k.
  • Fixes MetaFieldRanker silently treating a runtime top_k=0 as unset and falling back to the value configured at initialization. Runtime values that are not greater than zero now raise a ValueError as documented.

  • Fixed PythonCodeSplitter losing identifying context for oversized functions, methods, or classes. When a unit is too large and falls back to line-based secondary splitting, only the first resulting piece naturally retains the source def/class line; every piece now includes a qualified_name field in meta identifying the function, method, or class it came from.

  • Fixed RecursiveDocumentSplitter not setting the source_id meta field on the chunks it produces. It wrote only parent_id, while every other splitter in the library (DocumentSplitter, CSVDocumentSplitter, EmbeddingBasedDocumentSplitter, HierarchicalDocumentSplitter, MarkdownHeaderSplitter and PythonCodeSplitter) writes source_id. Components that follow that convention therefore rejected its output: SentenceWindowRetriever reads source_id by default and raises when it is absent, so it failed with "The retrieved documents must have 'source_id' in their metadata." on a pipeline that worked with any other splitter. Chunks now carry source_id as well as parent_id, which keeps its previous value for callers already reading it.

  • Tool functions defined in a module using from __future__ import annotations are now inspected correctly by Agent. Postponed annotations are stored as strings, so a parameter annotated with State was not recognized and the live State object was not injected into the tool call. The annotations are now resolved before they are inspected.

  • MarkdownHeaderSplitter and CSVDocumentSplitter now deep-copy the metadata of the document they split, matching DocumentSplitter. Previously they copied it shallowly, so nested values such as a list under meta["tags"] were shared between every chunk and with the input document, and editing one chunk's metadata changed all the others. HierarchicalDocumentSplitter had the same problem on its root node, which kept references into the input document's metadata.

  • Keep an Agent's execution counter in sync with step_count restored by a before_run hook, so restarted Agents continue from the saved step instead of resetting the count.

💙 Big thank you to everyone who contributed to this release!

@Aarkin7, @anakin87, @anxkhn, @aquib8112, @Aryan-Pardeshi, @atikulmunna, @bharadwaj-pendyala, @bilgeyucel, @bogdankostic, @camgrimsec, @chuenchen309, @davidpavlovschi, @davidsbatista, @DhanushPillay, @DivyaNarahari97, @erikos, @GovindhKishore, @hxaxd, @immuhammadfurqan, @iridescentWen, @jaideeppyne, @julian-risch, @kacperlukawski, @KXHXK, @LHMQ878, @LK-maker-007, @lntutor, @manjunathbhaskar, @mittalpk, @MVS-source, @onatozmenn, @otiscuilei, @pcbeingused333, @rautaditya2606, @sjrl, @sohumt123, @Solaris-star, @TimurRakhmatullin86, @vidigoat, @vinkiYu, @winklemad, @yaodong-shen

Don't miss a new haystack release

NewReleases is sending notifications on new releases.