๐Ÿ“ฃ Haystack 2.28 is here! Pass agent State directly to tools & components - no extra wiring needed

Haystack 2.28.0

Check on Github

โญ๏ธ Highlights

๐Ÿ”— Passing State to Tools and Components

Tools and components can now access the live agent State directly - no extra wiring needed. Just add a state: State parameter to your tool or component’s run method and ToolInvoker automatically injects the current state at runtime. The State parameter is hidden from the LLM-facing schema so the model is never asked to supply it.

For function-based tools created with @tool:

from haystack.components.agents import State
from haystack.tools import tool

@tool
def my_tool(query: str, state: State) -> str:
    """Search using context from agent state."""
    history = state.get("history")
    ...

For component-based tools created with ComponentTool:

from haystack import component
from haystack.components.agents import State
from haystack.tools import ComponentTool

@component
class MyComponent:
    @component.output_types(result=str)
    def run(self, query: str, state: State) -> dict:
        history = state.get("history")
        ...

tool = ComponentTool(component=MyComponent())

This is an alternative to the existing inputs_from_state and outputs_to_state options, which map individual state keys declaratively. Injecting the full State object is more flexible when a tool needs to read from or write to multiple keys.

โš ๏ธ Upgrade Notes

  • As part of the migration from requests to httpx, request_with_retry and async_request_with_retry (in haystack.utils.requests_utils) no longer raise requests.exceptions.RequestException on failure. They now raise httpx.HTTPError instead. This also affects HuggingFaceTEIRanker, which relies on these utilities. Update any code catching requests.exceptions.RequestException to catch httpx.HTTPError.

  • The LLM component now requires user_prompt to be provided at initialization and it must contain at least one Jinja2 template variable (e.g. {{ variable_name }}). required_variables now defaults to "*" (all variables in user_prompt are required), and passing an empty list raises a ValueError.

    Before:

    llm = LLM(chat_generator=OpenAIChatGenerator(), system_prompt="You are helpful.")
    

    After:

    llm = LLM(
        chat_generator=OpenAIChatGenerator(),
        system_prompt="You are helpful.",
        user_prompt='{% message role="user" %}{{ query }}{% endmessage %}',
    )
    
  • Agent.run() and Agent.run_async() now require messages as an explicit argument (no longer optional). If you were relying on the default None value, pass an empty list instead:

    agent.run(messages=[], ...)
    

โšก๏ธ Enhancement Notes

  • Clarified in the Markdown-producing converter documentation that DocumentCleaner with its default settings can flatten Markdown output. Updated the example pipelines for PaddleOCRVLDocumentConverter, MistralOCRDocumentConverter, AzureDocumentIntelligenceConverter, and MarkItDownConverter to avoid routing Markdown content through the default cleaner configuration.
  • Made _create_agent_snapshot robust towards serialization errors. If serializing agent component inputs fails, a warning is logged and an empty dictionary is used as a fallback, preventing the serialization error from masking the real pipeline runtime error.
  • Standardized HTTP request handling in Haystack by adopting httpx for both synchronous and asynchronous requests, replacing requests. Error reporting for failed requests has also been improved: exceptions now include additional details alongside the reason field.
  • Added run_async method to LLMMetadataExtractor. ChatGenerator requests now run concurrently using the existing max_workers init parameter.
  • MarkdownHeaderSplitter now accepts a header_split_levels parameter (list of integers 1โ€“6, default all levels) to control which header depths create split boundaries. For example, header_split_levels=[1, 2] splits only on # and ## headers, merging content under deeper headers into the preceding chunk.
  • MarkdownHeaderSplitter now ignores # lines that appear inside fenced code blocks (triple-backtick or triple-tilde), preventing Python comments and other hash-prefixed lines in code from being misidentified as Markdown headers.
  • Expanded the PaddleOCRVLDocumentConverter documentation with more detailed guidance on advanced parameters, common usage scenarios, and a more realistic configuration example for layout-heavy documents.

๐Ÿ› Bug Fixes

  • Fix ToolInvoker._merge_tool_outputs silently appending None to list-typed state when a tool’s outputs_to_state source key is absent from the tool result. This is a common scenario with PipelineTool wrapping a pipeline that has conditional branches where not all outputs are always produced even if defined in outputs_to_state. The mapping is now skipped entirely when the source key is not present in the result dict.

  • Fixed a bug in MarkdownHeaderSplitter where a child header lost its direct parent header in the metadata when the parent header had its own content chunk before the first child header.

    Previously, the following document:

    # header 1
    intro text
    
    ## header 1.1
    text 1
    

    would produce parent_headers: [] for header 1.1 instead of the expected ['header 1']. All parent header metadata is now correctly preserved in split chunks.

  • Reverted the change that made Agent messages optional, as it caused issues with pipeline execution. As a consequence, the LLM component now defaults to an empty messages list unless provided at runtime.

๐Ÿ’™ Big thank you to everyone who contributed to this release!

@Aftabbs, @Amanbig, @anakin87, @bilgeyucel, @bogdankostic, @davidsbatista, @dina-deifallah, @jimmyzhuu, @julian-risch, @kacperlukawski, @maxdswain, @MechaCritter, @ritikraj2425, @sarahkiener, @sjrl, @soheinze, @srini047, @tholor