v3.0.0-rc1
Pre-release⭐️ Highlights
Haystack 3.0 is a major release for building production-grade agents with full control and flexibility. It ships a wave of new capabilities: a more capable Agent with hooks and first-class skills, built-in run introspection, first-class async for serving, a leaner core, and safer pipeline loading.
A few small, intentional breaking changes come with it but our Migration Guide and Upgrading to Haystack 3.0 make it easy to upgrade.
🤖 A more capable, adaptable Agent
The Agent gained a general-purpose hooks system and now owns tool execution end to end (the standalone ToolInvoker has been removed). Hooks at before_run, before_llm, before_tool, after_tool, on_exit, and after_run let you shape behavior, enforce guardrails, and add human-in-the-loop checkpoints without touching the agent's internals:
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.hooks import hook
@hook
def audit_tool_calls(state):
pending = state.data["messages"][-1].tool_calls
print(f"about to run: {[tc.tool_name for tc in pending]}")
agent = Agent(
chat_generator=OpenAIChatGenerator(),
tools=[...],
hooks={"before_tool": [audit_tool_calls]},
)Built on this foundation:
- Skills as first-class citizens —
SkillToolsetdiscovers skills through progressive disclosure, so the model sees only names and one-line descriptions until it loads one, keeping context lean. - Dynamic tool selection at runtime — pass
tools=...torun/run_asyncso one reusable Agent serves different teams, tenants, and tasks. - Native async tools —
@toolroutesasync defcallables to aTool's newasync_function. - Human-in-the-Loop is now a
before_toolhook (ConfirmationHook).⚠️ - Tool result offloading (
ToolResultOffloadHook) writes large results to a store and leaves a compact pointer in the conversation.
🔎 Built-in introspection and observability
Agents now expose step_count, token_usage, and tool_call_counts as built-in state. React to them to compact context when the window fills, cap runaway tool loops, or route to a cheaper approach past a budget threshold. Tracing now emits dedicated step-level spans (haystack.agent.step with nested .llm / .tool children) tagged with the tools actually used, so you can see exactly what your agent did.
⚡ A core built for serving: first-class async
Pipeline and AsyncPipeline are now one class — no more switching between them. A single Pipeline exposes run, run_async, run_async_generator, and stream; serialized pipelines, SuperComponents, and PipelineTools load as Pipeline instances.
from haystack import Pipeline
pipeline = Pipeline()
result = pipeline.run(data) # synchronous
result = await pipeline.run_async(data) # asynchronousConcurrent tool calls and token-by-token streaming come standard: Pipeline.stream() yields StreamingChunks as they're produced and exposes the final output on handle.result — the lower latency and streaming UX serving apps need. Components and Pipeline also get a symmetric lifecycle — warm_up to acquire, close to release — so long-running services don't leak connections, GPU memory, or file handles. External resources (and API keys) are now created at warm_up, not in __init__.
🧱 A leaner, faster-moving framework ⚠️
Legacy Generators are gone, haystack-experimental is no longer a core dependency, and 30 components now live in independently released packages in haystack-core-integrations: Sentence Transformers, local & API Hugging Face, Whisper, spaCy/langdetect, Tika and Azure OCR, SerperDev/SearchApi, OpenAPI connectors, and the Datadog/OpenTelemetry tracers. As a result, there are fewer transitive dependencies to audit and we can ship independent of the core release cycle. Migration is an install plus an import change:
# pip install sentence-transformers-haystack
# Before: from haystack.components.embedders import SentenceTransformersTextEmbedder
from haystack_integrations.components.embedders.sentence_transformers import SentenceTransformersTextEmbedderThe full old → new import table is in MIGRATION.md.
💬 Chat Generators everywhere (legacy Generators removed) ⚠️
OpenAIGenerator, AzureOpenAIGenerator, HuggingFaceAPIGenerator, and HuggingFaceLocalGenerator have been removed in favor of their Chat Generator counterparts. To ease the switch, all Chat Generators now accept a plain str for messages:
# Before
from haystack.components.generators import OpenAIGenerator
text = OpenAIGenerator().run("What is NLP?")["replies"][0] # str
# After
from haystack.components.generators.chat import OpenAIChatGenerator
reply = OpenAIChatGenerator().run("What is NLP?")["replies"][0] # ChatMessage
text = reply.text # str; metadata in reply.metaDALLEImageGenerator was also renamed to OpenAIImageGenerator.
🔒 Safer pipeline loading ⚠️
Pipeline deserialization (Pipeline.load / loads / from_dict) is now gated by a trusted-module allowlist (haystack, haystack_integrations, haystack_experimental, builtins, typing, collections by default), and dangerous builtins like eval, exec, open, and getattr can no longer be resolved. Allowlist custom modules when needed:
Pipeline.load(fp, allowed_modules=["mypkg.*"]) # or HAYSTACK_DESERIALIZATION_ALLOWLIST / allow_deserialization_module(...)
Pipeline.load(fp, unsafe=True) # only if you fully trust the source🧪 Testing without the API bill
Newly added mock components mean no API keys, no network, no flaky CI for testing and prototyping. MockChatGenerator returns predefined responses, MockTextEmbedder and MockDocumentEmbedder return stable, hash-derived embeddings.
A few smaller breaking changes to know about
- Required template variables by default —
Agent,PromptBuilder, andChatPromptBuildernow treat every Jinja2 variable as required (required_variables=Nonerestores the old behavior); a new{% insert %}tag interleaves runtime messages.⚠️ - No more implicit
Stateinjection by name — tools must declareinputs_from_stateexplicitly to read aStatevalue; same-named parameters no longer fill in automatically.⚠️ - Deterministic
Document.id— computed from canonical, key-sorted JSON ofmeta, so documents with non-emptymetaget different IDs than in 2.x.⚠️ - Scoped logging —
configure_loggingattaches to Haystack's own loggers; importing Haystack no longer reconfiguresstructlogprocess-wide.⚠️ - Explicit tracing — no longer auto-enabled; add an
OpenTelemetryConnector/DatadogConnectoror calltracing.enable_tracing(...).⚠️
See the Upgrade Notes below for the full list.
⬆️ Upgrade Notes
-
AsyncPipelinehas been removed. Its asynchronous capabilities are now part of the singlePipelineclass, which provides both a synchronousrunmethod and the asynchronousrun_async,run_async_generator, andstreammethods (mirroring how components exposerunandrun_async).To migrate, replace
AsyncPipelinewithPipeline:# Before from haystack import AsyncPipeline pipeline = AsyncPipeline() result = await pipeline.run_async(data) # After from haystack import Pipeline pipeline = Pipeline() result = await pipeline.run_async(data)
Serialized pipelines,
SuperComponents, andPipelineTools are loaded asPipelineinstances.Pipeline.runruns synchronously and blocks until completion; in an async context useawait pipeline.run_async(...). The formerAsyncPipeline.runwas a synchronous wrapper around the concurrent engine, so to preserve that behavior useasyncio.run(pipeline.run_async(...)). Note thatPipeline.runexecutes components sequentially and does not acceptconcurrency_limit.Both synchronous and asynchronous runs are traced under a single
haystack.pipeline.runoperation name, distinguished by ahaystack.pipeline.execution_modetag (syncorasync). Previously, asynchronous runs used thehaystack.async_pipeline.runoperation name. -
continue_runis now a reserved key inAgent.state_schema(set by anon_exithook to keep the Agent running). Passing it via thestate_schemaargument now raisesValueError. Rename any conflicting state key (e.g.my_continue_run) to migrate. -
Agentno longer validatesexit_conditionsagainst tool names at initialization. Exit conditions are now evaluated at runtime, so a condition naming a tool that is loaded later — e.g. a tool passed at runtime or one discovered bySearchableToolset— works as expected. Consequently, an unknown exit-condition name no longer raises aValueErrorat construction. -
Tool support is now validated against the tools resolved for each run, not only the tools provided at initialization. Passing tools at runtime via
run(tools=...)to a chat generator that does not support tools now raises aTypeErrorinstead of silently ignoring them. -
Agentnow treats every Jinja2 template variable inuser_promptandsystem_promptas required by default. The default of therequired_variablesinit parameter has changed fromNone(all variables optional) to"*"(all variables required). This alignsAgentwithLLM,PromptBuilder, andChatPromptBuilder.To check if you're affected: look for
Agent(...)calls that pass auser_promptorsystem_promptcontaining template variables without passingrequired_variables. If those Agents relied on missing variables being silently rendered as empty strings, the behavior depends on how the missing variable was reaching the Agent:- Calling
agent.run()directly without providing a required variable now raisesValueError. - In a pipeline, if a required variable is neither provided as a user input nor connected from another component,
Pipeline.runraisesValueError("Missing mandatory input '<var>' for component '<name>'."). - In a pipeline, if a required variable is connected to a sender that never produces output on a given run (for example, a loop-fed input on the first iteration or a conditional branch that isn't taken), the pipeline cannot make progress and raises
PipelineComponentsBlockedError.
Migration options:
- To require only a subset of variables, pass
required_variables=["var1", "var2"]. - To preserve the previous "all optional" behavior, explicitly pass
required_variables=None. - To require everything (the new default), no change is needed.
- Calling
-
Components in Haystack that use external resources now consistently create those resources during warm-up rather than in
__init__. As a result, some components that previously raised errors during initialization (for example, because of a missing API key) now raise them duringwarm_upinstead. Similar changes will be rolled out to components in Haystack Core Integrations in the future. -
step_count,token_usage, andtool_call_countsare now reserved keys inAgent.state_schema. Passing any of them via thestate_schemaargument now raisesValueError. Rename the conflicting state key (e.g.my_token_usage) to migrate. -
Confirmation strategies now receive the model-requested tool arguments in
tool_paramsrather than the fully-prepared arguments. Values injected fromStatevia a tool'sinputs_from_statemapping (and theStateobject injected intoState-typed parameters) are no longer included in what is presented for confirmation — that injection now happens only at tool execution time. If yourConfirmationUIorConfirmationPolicydisplayed or inspected those state-injected values, it will now see only the arguments the model provided. -
The hash used to auto-generate
Document.idis now computed from a canonical (key-sorted) JSON serialization ofmeta. Documents with emptymetaare unaffected, but most other documents will get different IDs than they did before:- documents with non-empty
meta(the serialization changes fromdict's repr to JSON); - documents whose
metacontains non-JSON-serializable values such asdatetimeor custom classes (these are now serialized viastr(...)rather thanrepr(...), e.g."2024-01-01 00:00:00"instead of"datetime.datetime(2024, 1, 1, 0, 0)").
If you rely on auto-generated IDs to match documents already persisted in a
DocumentStore, you will need to re-ingest the affected documents (or pass the previousidexplicitly when constructing theDocument). - documents with non-empty
-
Pipeline deserialization (
Pipeline.load/Pipeline.loads/Pipeline.from_dict) now refuses to import classes from modules outside a trusted-module allowlist. By default the allowlist containshaystack,haystack_integrations,haystack_experimental,builtins,typing, andcollections. Pipelines that reference custom components or callables in other packages will fail to load with aDeserializationErroruntil the additional modules are added to the allowlist.To restore loading of such pipelines, extend the allowlist via one of:
- per-call kwarg:
Pipeline.load(fp, allowed_modules=["mypkg.*"]); - process-wide programmatic API:
from haystack.core.serialization import allow_deserialization_module; - environment variable:
HAYSTACK_DESERIALIZATION_ALLOWLIST="mypkg.*,otherpkg.*"; - or, if the source of the serialized data is fully trusted, bypass the allowlist with
Pipeline.load(fp, unsafe=True).
- per-call kwarg:
-
During pipeline deserialization,
default_from_dictnow validates the keys ofinit_parametersagainst the parent class's__init__signature before recursing into any nested{"type": "...", "init_parameters": {...}}dictionary. A nested dict whose key is not an accepted parameter name on the parent class is rejected with aDeserializationErrorbefore the nested type is imported. Classes whose constructor takes**kwargsare exempt, since their accepted parameter set cannot be statically determined.This may surface pre-existing YAML bugs — e.g. typos, leftovers from renamed or removed parameters, or stale snapshots from older Haystack versions. The fix is to update the YAML so each nested-component key matches a real
__init__parameter on the parent class. -
PromptBuilderandChatPromptBuildernow treat every Jinja2 template variable as required by default. Previously, variables were optional by default unless explicitly listed inrequired_variables.To check if you're affected: look for
PromptBuilder(...)orChatPromptBuilder(...)calls in your code that do not passrequired_variables. If those components relied on missing variables being silently rendered as empty strings, the behavior depends on how the missing variable was reaching the builder:- Calling
builder.run()directly without providing a required variable now raisesValueError. - In a pipeline, if a required variable is neither provided as a user input nor connected from another component,
Pipeline.runraisesValueError("Missing mandatory input '<var>' for component '<name>'."). - In a pipeline, if a required variable is connected to a sender that never produces output on a given run (for example, a loop-fed input on the first iteration or a conditional branch that isn't taken), the pipeline cannot make progress and raises
PipelineComponentsBlockedError.
Migration options:
- To require only a subset of variables, pass
required_variables=["var1", "var2"]. - To preserve the previous "all optional" behavior, explicitly pass
required_variables=None. - To require everything (the new default), no change is needed.
- Calling
-
Human-in-the-Loop tool confirmation on
Agentis now expressed as abefore_toolhook instead of a dedicated concept. Theconfirmation_strategiesandconfirmation_strategy_contextparameters have been removed fromAgent.__init__,Agent.run, andAgent.run_async. To migrate, wrap your confirmation strategies in the newConfirmationHook, register it under thebefore_toolhook point, and pass any request-scoped resources through the generichook_contextrun argument instead ofconfirmation_strategy_context. The Human-in-the-Loop module has also moved fromhaystack.human_in_the_looptohaystack.hooks.human_in_the_loop, so that it lives alongside the other built-in hooks (such as tool result offloading); update your imports to the new location:from haystack.components.agents import Agent from haystack.hooks.human_in_the_loop import ( AlwaysAskPolicy, BlockingConfirmationStrategy, ConfirmationHook, RichConsoleUI, ) confirmation_hook = ConfirmationHook( confirmation_strategies={ "my_tool": BlockingConfirmationStrategy( confirmation_policy=AlwaysAskPolicy(), confirmation_ui=RichConsoleUI() ) } ) agent = Agent(chat_generator=..., tools=[...], hooks={"before_tool": [confirmation_hook]}) agent.run(messages=[...], hook_context={"websocket": ws})
-
The agent-specific breakpoint API has been removed. The
AgentBreakpoint,ToolBreakpoint, andAgentSnapshotdataclasses are no longer exported fromhaystack.dataclasses, and thebreak_point,snapshot, andsnapshot_callbackparameters have been removed fromAgent.runandAgent.run_async.Pipeline.runno longer acceptsAgentBreakpointfor itsbreak_pointargument, and theagent_snapshotfield has been removed fromPipelineSnapshot. Pipeline-level breakpoints viaBreakpointandPipelineSnapshotcontinue to work as before, but pausing and resuming execution inside an Agent (at the chat generator or tool invoker) is no longer supported. -
AzureOCRDocumentConverterhas been moved out of Haystack into theazure-form-recognizer-haystackintegration package. Install the new package withpip install azure-form-recognizer-haystackand update your imports.Before:
from haystack.components.converters import AzureOCRDocumentConverter
After:
from haystack_integrations.components.converters.azure_form_recognizer import AzureOCRDocumentConverter
-
The
DatadogTracerhas been moved out of Haystack into thedatadog-haystackintegration package. It is no longer auto-enabled whenddtraceis installed; instead, use the newDatadogConnectorcomponent to enable Datadog tracing in your pipeline. Install the new package withpip install datadog-haystack.Before:
import ddtrace from haystack import tracing from haystack.tracing.datadog import DatadogTracer tracing.enable_tracing(DatadogTracer(ddtrace.tracer))
After:
from haystack import Pipeline from haystack_integrations.components.connectors.datadog import DatadogConnector pipe = Pipeline() pipe.add_component("tracer", DatadogConnector())
Alternatively, you can still enable the tracer manually:
import ddtrace from haystack import tracing from haystack_integrations.tracing.datadog import DatadogTracer tracing.enable_tracing(DatadogTracer(ddtrace.tracer))
-
OpenAIGenerator,AzureOpenAIGenerator,HuggingFaceAPIGenerator, andHuggingFaceLocalGeneratorhave been removed. Use the Chat Generators instead:OpenAIChatGenerator,AzureOpenAIChatGenerator,HuggingFaceAPIChatGenerator, andHuggingFaceLocalChatGenerator. As of v3.0, all ChatGenerators accept a plainstras input and normalise it to a user message.Before:
from haystack.components.generators import OpenAIGenerator gen = OpenAIGenerator() text = gen.run("What is NLP?")["replies"][0] # str
After:
from haystack.components.generators.chat import OpenAIChatGenerator gen = OpenAIChatGenerator() reply = gen.run("What is NLP?")["replies"][0] # ChatMessage text = reply.text # str; metadata is in reply.meta
-
DALLEImageGeneratorhas been renamed toOpenAIImageGeneratorand moved tohaystack.components.generators.openai_image_generator. The rename reflects that OpenAI retired the DALL-E model family but the component works with newer image generation models.Before:
from haystack.components.generators import DALLEImageGenerator generator = DALLEImageGenerator()
After:
from haystack.components.generators import OpenAIImageGenerator generator = OpenAIImageGenerator()
-
haystack-experimentalis no longer a core dependency of Haystack and is no longer installed automatically withhaystack-ai. If your code imports fromhaystack_experimental(directly or through an integration that relies on it), you must now install it explicitly, for example withpip install haystack-experimental. Installations that do not usehaystack_experimentalare unaffected. -
HuggingFaceAPIChatGenerator,HuggingFaceAPITextEmbedder,HuggingFaceAPIDocumentEmbedder, andHuggingFaceTEIRankerhave been moved out of Haystack into thehuggingface-api-haystackintegration package. Install the new package withpip install huggingface-api-haystackand update your imports.Before:
from haystack.components.generators.chat import HuggingFaceAPIChatGenerator from haystack.components.embedders import HuggingFaceAPITextEmbedder, HuggingFaceAPIDocumentEmbedder from haystack.components.rankers import HuggingFaceTEIRanker
After:
from haystack_integrations.components.generators.huggingface_api import HuggingFaceAPIChatGenerator from haystack_integrations.components.embedders.huggingface_api import ( HuggingFaceAPITextEmbedder, HuggingFaceAPIDocumentEmbedder, ) from haystack_integrations.components.rankers.huggingface_api import HuggingFaceTEIRanker
-
DocumentLanguageClassifierandTextLanguageRouterhave been moved out of Haystack into thelangdetect-haystackintegration package. Install the new package withpip install langdetect-haystackand update your imports.Before:
from haystack.components.classifiers import DocumentLanguageClassifier from haystack.components.routers import TextLanguageRouter
After:
from haystack_integrations.components.classifiers.langdetect import DocumentLanguageClassifier from haystack_integrations.components.routers.langdetect import TextLanguageRouter
-
The
NamedEntityExtractorcomponent (along with theNamedEntityAnnotationandNamedEntityExtractorBackendclasses) has been removed from Haystack and moved into dedicated integration packages, one per backend.For the Hugging Face backend, install
transformers-haystackand update your imports:Before:
from haystack.components.extractors import NamedEntityExtractor extractor = NamedEntityExtractor(backend="hugging_face", model="dslim/bert-base-NER")
After:
from haystack_integrations.components.extractors.transformers import TransformersNamedEntityExtractor extractor = TransformersNamedEntityExtractor(model="dslim/bert-base-NER")
For the spaCy backend, install
spacy-haystackand update your imports:Before:
from haystack.components.extractors import NamedEntityExtractor extractor = NamedEntityExtractor(backend="spacy", model="en_core_web_trf")
After:
from haystack_integrations.components.extractors.spacy import SpacyNamedEntityExtractor extractor = SpacyNamedEntityExtractor(model="en_core_web_trf")
-
The
OpenAPIConnector,OpenAPIServiceConnectorandOpenAPIServiceToFunctionscomponents have been removed from Haystack and moved into the dedicatedopenapi-haystackintegration package.Install the package and update your imports:
Before:
from haystack.components.connectors import OpenAPIConnector, OpenAPIServiceConnector from haystack.components.converters import OpenAPIServiceToFunctions
After:
from haystack_integrations.components.connectors.openapi import OpenAPIConnector, OpenAPIServiceConnector from haystack_integrations.components.converters.openapi import OpenAPIServiceToFunctions
These components are a legacy way to connect Haystack to external APIs. For most use cases, consider using the
MCPToolinstead, which is the modern, standardized way to give pipelines and agents access to external tools and services. -
The
OpenTelemetryTracerhas been moved out of Haystack into theopentelemetry-haystackintegration package, and theopentelemetry-sdkdependency is no longer installed with Haystack. OpenTelemetry tracing is no longer auto-enabled whenopentelemetry-sdkis installed; instead, use the newOpenTelemetryConnectorcomponent to enable OpenTelemetry tracing in your pipeline. Install the new package withpip install opentelemetry-haystack.Before:
from opentelemetry import trace from haystack import tracing from haystack.tracing import OpenTelemetryTracer tracing.enable_tracing(OpenTelemetryTracer(trace.get_tracer("my_application")))
After:
from opentelemetry import trace from haystack import tracing from haystack_integrations.tracing.opentelemetry import OpenTelemetryTracer tracing.enable_tracing(OpenTelemetryTracer(trace.get_tracer("my_application")))
Alternatively, add the
OpenTelemetryConnectorto your pipeline to enable tracing:from haystack import Pipeline from haystack_integrations.components.connectors.opentelemetry import OpenTelemetryConnector pipe = Pipeline() pipe.add_component("tracer", OpenTelemetryConnector())
-
Breaking change:
haystack.tracing.auto_enable_tracinghas been removed (and is no longer called onimport haystack). Since Haystack no longer ships a built-in tracing backend, there is nothing to auto-enable. Enable tracing explicitly instead, for example by adding theOpenTelemetryConnectorcomponent to your pipeline, or by callinghaystack.tracing.enable_tracing(...)with your tracer. TheHAYSTACK_AUTO_TRACE_ENABLEDenvironment variable no longer has any effect. -
SearchApiWebSearchhas been moved out of Haystack into thesearchapi-haystackintegration package. Install the new package withpip install searchapi-haystackand update your imports.Before:
from haystack.components.websearch import SearchApiWebSearch
After:
from haystack_integrations.components.websearch.searchapi import SearchApiWebSearch
-
The Sentence Transformers components have been moved out of Haystack into the
sentence-transformers-haystackintegration package. This includesSentenceTransformersTextEmbedder,SentenceTransformersDocumentEmbedder,SentenceTransformersSparseTextEmbedder,SentenceTransformersSparseDocumentEmbedder,SentenceTransformersDocumentImageEmbedder,SentenceTransformersSimilarityRanker, andSentenceTransformersDiversityRanker. Install the new package withpip install sentence-transformers-haystackand update your imports.Before:
from haystack.components.embedders import ( SentenceTransformersTextEmbedder, SentenceTransformersDocumentEmbedder, SentenceTransformersSparseTextEmbedder, SentenceTransformersSparseDocumentEmbedder, ) from haystack.components.embedders.image import SentenceTransformersDocumentImageEmbedder from haystack.components.rankers import SentenceTransformersSimilarityRanker, SentenceTransformersDiversityRanker
After:
from haystack_integrations.components.embedders.sentence_transformers import ( SentenceTransformersTextEmbedder, SentenceTransformersDocumentEmbedder, SentenceTransformersSparseTextEmbedder, SentenceTransformersSparseDocumentEmbedder, SentenceTransformersDocumentImageEmbedder, ) from haystack_integrations.components.rankers.sentence_transformers import ( SentenceTransformersSimilarityRanker, SentenceTransformersDiversityRanker, )
-
Removed the unused
serialize_class_instanceanddeserialize_class_instancefunctions fromhaystack.utils.base_serialization. These were internal helpers not used anywhere in Haystack. If you were importing them directly, inline their logic: use{"type": generate_qualified_class_name(type(obj)), "data": obj.to_dict()}to serialize, andobj_class.from_dict(data["data"])(after resolvingobj_classwithimport_class_by_name(data["type"])) to deserialize. -
SerperDevWebSearchhas been moved out of Haystack into theserperdev-haystackintegration package. Install the new package withpip install serperdev-haystackand update your imports.Before:
from haystack.components.websearch import SerperDevWebSearch
After:
from haystack_integrations.components.websearch.serperdev import SerperDevWebSearch
-
TikaDocumentConverterhas been moved out of Haystack into thetika-haystackintegration package. Install the new package withpip install tika-haystackand update your imports.Before:
from haystack.components.converters import TikaDocumentConverter
After:
from haystack_integrations.components.converters.tika import TikaDocumentConverter
-
Removed the
ToolInvokercomponent. Code that importsToolInvokerfromhaystack.components.toolsor uses it as a standalone Pipeline component must be updated.To execute tools in an agentic loop, pass the tools directly to
haystack.components.agents.Agent. The Agent now owns tool execution, including tool/toolset warm-up, state injection, streaming callback passthrough, and sync/async tool invocation. The previoustool_invoker_kwargsAgent parameter has been removed; usetool_concurrency_limitandtool_streaming_callback_passthroughinstead. -
Tool results are now always serialized to a string using
json.dumps(withensure_ascii=False, falling back tostronly when the result is not JSON-serializable). TheToolInvoker'sconvert_result_to_json_stringparameter, which defaulted toFalseand selectedstr-based serialization, has been removed along with the component. As a result, non-string tool results now appear as JSON rather than Pythonreproutput: for example, a tool returning{"message": "hi"}now produces the string{"message": "hi"}instead of{'message': 'hi'}. Update any code or tests that assert on the previousstr-based format. -
HuggingFaceLocalChatGenerator,ExtractiveReader,TransformersZeroShotDocumentClassifier,TransformersTextRouter, andTransformersZeroShotTextRouterhave been moved out of Haystack into thetransformers-haystackintegration package. Install the new package withpip install transformers-haystackand update your imports.Before:
from haystack.components.classifiers import TransformersZeroShotDocumentClassifier from haystack.components.generators.chat import HuggingFaceLocalChatGenerator from haystack.components.readers import ExtractiveReader from haystack.components.routers import TransformersTextRouter, TransformersZeroShotTextRouter
After:
from haystack_integrations.components.classifiers.transformers import TransformersZeroShotDocumentClassifier from haystack_integrations.components.generators.transformers import TransformersChatGenerator from haystack_integrations.components.readers.transformers import TransformersExtractiveReader from haystack_integrations.components.routers.transformers import ( TransformersTextRouter, TransformersZeroShotTextRouter, )
-
The legacy
TransformersSimilarityRankercomponent has been removed. UseSentenceTransformersSimilarityRankerinstead, which provides the same functionality along with async support.Before:
from haystack.components.rankers import TransformersSimilarityRanker ranker = TransformersSimilarityRanker(model="cross-encoder/ms-marco-MiniLM-L-6-v2")
After:
from haystack.components.rankers import SentenceTransformersSimilarityRanker ranker = SentenceTransformersSimilarityRanker(model="cross-encoder/ms-marco-MiniLM-L-6-v2")
-
The
user_promptandsystem_promptparameters have been removed fromAgent.run/Agent.run_asyncandLLM.run/LLM.run_async. Both prompts must now be set at initialization time on the component; they can no longer be overridden per-run, including when the component is used inside a pipeline (Pipeline.run(data={"agent": {"user_prompt": ...}})orPipeline.run(data={"llm": {"user_prompt": ...}})is no longer supported).system_promptanduser_promptboth accept either a plain string template or an explicit Jinja2 message template. Plain string templates are rendered as a single message with the expected role (systemforsystem_prompt,userforuser_prompt). Explicit Jinja2 message templates must contain a single message block and render to exactly one message with the matching role.Before:
agent = Agent(chat_generator=..., tools=[...], system_prompt="Default system prompt.") agent.run(messages=[...], system_prompt="Per-run override.")
After:
# Construct a new Agent or LLM with the desired prompt. agent = Agent(chat_generator=..., tools=[...], system_prompt="Default system prompt.") agent.run(messages=[...]) # If you need to build prompts at runtime, construct an Agent without # init prompts and pass the prompt messages through the messages input. agent = Agent(chat_generator=..., tools=[...]) agent.run(messages=[ChatMessage.from_system("Runtime system prompt."), ChatMessage.from_user("...")])
-
LocalWhisperTranscriberandRemoteWhisperTranscriberhave been moved out of Haystack into thewhisper-haystackintegration package. Install the new package withpip install whisper-haystackand update your imports.Before:
from haystack.components.audio import LocalWhisperTranscriber from haystack.components.audio import RemoteWhisperTranscriber
After:
from haystack_integrations.components.audio.whisper import LocalWhisperTranscriber from haystack_integrations.components.audio.whisper import RemoteWhisperTranscriber
LocalWhisperTranscriberadditionally requires theopenai-whisperpackage (andffmpeg), which you can install withpip install "openai-whisper>=20231106". -
Tools now read a value from the
Agent'sStateby name only when they declare an explicitinputs_from_statemapping. Previously, when a tool had noinputs_from_state, theAgentwould implicitly inject aStatevalue into any tool parameter whose name happened to match aStatekey, on any call where the model did not already supply that argument (LLM-provided arguments always took precedence). This implicit name-matching has been removed. The change applies to every tool type, sinceComponentTool,PipelineTool,MCPTool, and others all derive from the baseToolclass.This is a breaking change. If you relied on a tool parameter being filled from a same-named
Statekey, add an explicit mapping, for exampleinputs_from_state={"documents": "documents"}to feed thedocumentsstate key into the tool'sdocumentsparameter.Auto-injection of the full
Stateobject into a parameter annotated asStateis unchanged and continues to work without any mapping. -
The
raise_on_invalid_filter_syntaxutility function has been removed. Its implementation was incorrect and never used across Haystack and Haystack Core Integrations. -
haystack.logging.configure_loggingnow attaches its formatting handler to Haystack's own logger namespaces (haystack,haystack_integrationsandhaystack_experimental) instead of the root logger. This makes Haystack a better-behaved library when it runs next to other services in the same process: the log records of the host application and other libraries are no longer reformatted or duplicated by Haystack. To restore the previous behavior of formatting every log record in the process, callconfigure_logging(logger_name=""). -
Importing Haystack no longer configures
structlogprocess-wide. The import-time setup now only installs Haystack's own scoped logging handler (so Haystack's logs are still formatted) and leaves the globalstructlogconfiguration untouched, so the host application's own nativestructlogloggers are no longer reformatted by merely importing Haystack.structlogis configured only when you callconfigure_logging(configure_structlog=True). -
The
Agentcomponent now emits a different shape of tracing spans. Previously each iteration of the agent loop produced two child spans viaPipeline._run_component— one for the chat generator and one for the tool invoker — each carryinghaystack.component.nameandhaystack.component.typetags. The agent now opens a singlehaystack.agent.stepspan per loop iteration with two nested children:haystack.agent.step.llm(the chat generator call) andhaystack.agent.step.tool(the tool invocation, only emitted when tool calls happen). These new spans do NOT carryhaystack.component.*tags; instead they expose new content tagshaystack.agent.step.llm.input/.outputandhaystack.agent.step.tool.input/.output.Custom tracer backends or
SpanHandlerimplementations that dispatched oncomponent_type == "ToolInvoker"orcomponent_type.endswith("ChatGenerator")for Agent-internal spans will need to be updated to dispatch on the new operation names (haystack.agent.step.llm/haystack.agent.step.tool) instead. The Langfuse integration'sDefaultSpanHandleris affected — seeMIGRATION.mdfor a concrete example of a customSpanHandlerthat maps the new operations to Langfusechain/generation/toolobservations.
🚀 New Features
-
Added an
after_toolhook point to theAgent. Hooks registered underafter_toolrun after tools execute, once their result messages are instate.data["messages"], and before the exit check and the next LLM call. Use it to rewrite the freshly produced tool-result messages, for example to offload, redact, truncate, or summarize results before the next LLM call sees them. It does not run on the plain-text exit step, where no tools run. Register it like any other hook:hooks={"after_tool": [my_hook]}. -
Added hooks to the
Agent. Passhooksas a dictionary mapping a hook point to a list of hooks the Agent runs at that point. Each hook receives the liveStateand influences the run by mutating it in place; hooks for a hook point run in list order, and the same hook can be registered under multiple hook points. Valid hook points are:before_llm: Runs before each chat-generator call.before_tool: Runs after the model requests tool calls, before any tools run. After these hooks run, the Agent re-reads the current last message fromstate["messages"]. If that message contains tool calls, those calls are executed. If it does not, no tools run for that step, no tool-based exit condition is triggered, and the Agent loops back to the next LLM call unlessmax_agent_stepshas been reached.on_exit: Runs when the Agent is about to stop on an exit condition. Anon_exithook can keep the Agent running by setting thecontinue_runcontrol flag (state.set("continue_run", True)), usually alongside a message telling the model what to do next.on_exithooks run when the Agent stops on an exit condition, but not when it stops becausemax_agent_stepsis reached.
A hook is any object with a
run(state)method (optionallyrun_async(state)); use the@hookdecorator to build one from a function. This enables patterns such as building run-time system context, retrieving memories, and requiring a tool to be called before finishing.The example below registers one hook at each placement to show what each can do:
from datetime import datetime, timezone from typing import Annotated from haystack.components.agents import Agent from haystack.components.agents.state import State, replace_values from haystack.components.generators.chat import OpenAIChatGenerator from haystack.dataclasses import ChatMessage from haystack.hooks import hook from haystack.tools import tool @tool def search(query: Annotated[str, "The search query"]) -> str: """Search the web.""" # Placeholder: would call a real search API return "Fusion startups reported net-energy-gain milestones this year." @hook def build_context(state: State) -> None: # before_llm: build run-time system context once, before the first model call. if state.get("step_count") == 0: now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") system = ChatMessage.from_system(f"You are a research assistant. The current time is {now}.") state.set("messages", [system, *state.data["messages"]], handler_override=replace_values) @hook def audit_tool_calls(state: State) -> None: # before_tool: see which tools the model is about to run. pending = state.data["messages"][-1].tool_calls print(f"about to run: {[tc.tool_name for tc in pending]}") @hook def require_search(state: State) -> None: # on_exit: keep going until the agent has actually searched. if state.get("tool_call_counts", {}).get("search", 0) == 0: state.set("messages", [ChatMessage.from_system("Search before answering.")]) state.set("continue_run", True) agent = Agent( chat_generator=OpenAIChatGenerator(model="gpt-5.4-nano"), tools=[search], hooks={ "before_llm": [build_context], "before_tool": [audit_tool_calls], "on_exit": [require_search], }, ) result = agent.run(messages=[ChatMessage.from_user("What are the latest developments in fusion energy?")]) print(result["last_message"].text)
Class-based hooks may also implement the optional lifecycle methods
warm_up/warm_up_asyncandclose/close_async. The Agent calls them from its ownwarm_up/warm_up_asyncandclose/close_async, so a hook can defer opening clients or reading credentials until warm-up and release them on close. When a class-based hook should be serializable, store serializable constructor arguments on the hook and rebuild runtime clients from those values. For example, anon_exithook can grade the Agent's answer with its own LLM and ask it to improve a weak answer before finishing, warming the judge's client during the Agent's warm-up:from typing import Any from haystack.core.serialization import default_from_dict, default_to_dict class GradeFinalAnswer: """Grade the Agent's answer with an LLM and ask it to improve a weak answer before finishing.""" def __init__(self, model: str = "gpt-5.4-nano"): self.model = model self._judge = OpenAIChatGenerator(model=self.model) def warm_up(self) -> None: # Warm up the judge's own client during the Agent's warm-up. self._judge.warm_up() def run(self, state: State) -> None: answer = state.data["messages"][-1].text or "" verdict = self._judge.run( messages=[ChatMessage.from_user(f"Reply with only PASS or FAIL. Is this answer complete?\n\n{answer}")] )["replies"][0].text or "" if "FAIL" in verdict.upper(): state.set("messages", [ChatMessage.from_user("Your answer was incomplete. Please improve it.")]) state.set("continue_run", True) def to_dict(self) -> dict[str, Any]: return default_to_dict(self, model=self.model) @classmethod def from_dict(cls, data: dict[str, Any]) -> "GradeFinalAnswer": return default_from_dict(cls, data) agent = Agent(chat_generator=OpenAIChatGenerator(model="gpt-5.4-nano"), hooks={"on_exit": [GradeFinalAnswer()]}) result = agent.run(messages=[ChatMessage.from_user("Explain how vaccines work.")]) print(result["last_message"].text)
-
Add native async tool invocation.
Toolnow accepts an optionalasync_function(a coroutine function) alongside the existingfunctionfield. WhenAgent.run_asyncinvokes a tool, it now awaits theasync_functionof Tool if available.@tool/create_tool_from_functionautomatically routesasync defcallables toasync_function, so simply decorating anasync defproduces an async tool:from typing import Annotated from haystack.tools import tool @tool async def weather(city: Annotated[str, "The name of the city"]) -> str: """Get the weather for a city.""" ...
ComponentToolautomatically wires an async invoker for components that definerun_async(__haystack_supports_async__isTrue).PipelineToolenables the async path when wrapping anAsyncPipelineand falls back to the threaded sync path when wrapping a regularPipeline. -
Added
before_runandafter_runhook points to theAgent. Hooks registered underbefore_runrun exactly once per run, after the state is initialized and before the first LLM call — use them to rewrite the initial messages or seed state without re-running on every step likebefore_llmdoes. Hooks registered underafter_runrun exactly once per run, after the step loop has ended and before the Agent builds its return value, regardless of whether the run stopped on an exit condition or becausemax_agent_stepswas reached (unlikeon_exit); mutations to the state are reflected in the returnedmessages,last_message, andstate_schemaoutputs. Register them like any other hook:hooks={"before_run": [my_hook], "after_run": [my_other_hook]}. -
Added a new
{% insert %}tag to the Jinja2 string templates used byChatPromptBuilder. The tag is a placeholder that evaluates an expression to aChatMessageor a list ofChatMessageobjects and expands it into the prompt, so messages provided at runtime can be interleaved with literal{% message %}blocks. For example, you can wrap the runtime messages with a system message above and a templated user message below, then pass the messages (and any template variables) at run time:from haystack.components.builders import ChatPromptBuilder from haystack.dataclasses import ChatMessage template = """ {% message role="system" %}You are a helpful assistant.{% endmessage %} {% insert messages %} {% message role="user" %}{{ query }}{% endmessage %} """ builder = ChatPromptBuilder(template=template) result = builder.run( messages=[ChatMessage.from_user("Hi"), ChatMessage.from_assistant("Hello!")], query="What's the weather?", ) # result["prompt"] -> [system, user "Hi", assistant "Hello!", user "What's the weather?"]
All content types (tool calls, tool call results, images, reasoning,
nameandmeta) round trip without loss. A missing or empty value expands to nothing.The expression can be a plain variable (
{% insert messages %}), a slice or index ({% insert messages[-1:] %},{% insert messages[-1] %}), or a combination of variables ({% insert previous + current %}). Multiple{% insert %}tags can be used in a single template, so the runtime messages can be split, reordered, or repeated across different positions. -
Added
MockChatGenerator, a Chat Generator that returns predefined responses without calling any API. It is a deterministic, zero-cost drop-in replacement for real Chat Generators in tests, smoke tests, and quick prototypes. It supports a fixed response, a list of responses cycled across calls (useful to drive Agent-like loops), aresponse_fncallable for input-dependent replies, and an echo mode (the default) that returns the last user message. It implements the full Chat Generator interface, includingrun,run_async, streaming callbacks, and serialization. -
Added
MockTextEmbedderandMockDocumentEmbedder, embedders that return deterministic embeddings without calling any API. They are zero-cost drop-in replacements for real embedders in tests, smoke tests, and quick prototypes. By default each embedding is derived from a stable hash of the (prepared) text, so the same text always yields the same unit-length embedding and different texts yield different embeddings, making the mocks usable in retrieval pipelines and reproducible across runs and processes. A fixedembeddingvector or a customembedding_fncallable can be provided instead. Both implement the full embedder interface, includingrun,run_async, and serialization. -
Added a
reasoning_modeflattened generation kwarg toOpenAIResponsesChatGenerator, which is merged into thereasoningdictionary sent to the OpenAI Responses API. This lets you set the reasoning mode (for examplestandardorpro, supported since GPT-5.6) without having to construct the nestedreasoningdict yourself. -
Added an
include_reasoning_encrypted_contentflattened generation kwarg toOpenAIResponsesChatGenerator. Setting it toTrueappendsreasoning.encrypted_contentto theincludelist sent to the OpenAI Responses API, so reasoning items can be reused across multi-turn conversations when the Responses API is used statelessly (for example whenstoreisFalseor zero data retention is enabled). -
Added a
run_asyncmethod toLLMRanker,QueryExpander,LLMMessagesRouterandLLMDocumentContentExtractorso they can run natively in anAsyncPipeline. The method delegates to the chat generator'srun_asyncmethod when available. If the chat generator only implements a synchronousrunmethod, it is executed in a thread to avoid blocking the event loop.LLMDocumentContentExtractorprocesses documents concurrently withasyncio, bounded bymax_workers. -
Added a
run_asyncmethod toRemoteWhisperTranscriberandOpenAIImageGeneratorso they can run natively in anAsyncPipeline. Both components now hold anAsyncOpenAIclient alongside the existing synchronous client and call the OpenAI API without blocking the event loop. -
Added
SkillToolset, aToolsetthat lets anAgentdiscover and read "skills" through progressive disclosure, similar to how Claude Code and Codex expose skills. A skill is a directory containing aSKILL.mdfile with YAML frontmatter (nameanddescription) and a markdown body of instructions, optionally bundling additional reference files.SkillToolsetis backed by aSkillStore; useFileSystemSkillStoreto load skills from a local directory and add the toolset to anAgent:from haystack.components.agents import Agent from haystack.components.generators.chat import OpenAIChatGenerator from haystack.tools import SkillToolset from haystack.skill_stores.file_system import FileSystemSkillStore skills_toolset = SkillToolset(FileSystemSkillStore("skills/")) agent = Agent(chat_generator=OpenAIChatGenerator(), tools=skills_toolset)
Skills are discovered when the toolset is warmed up (the
Agentdoes this automatically before a run). The names and descriptions of all discovered skills are baked into theload_skilltool description, so the model can see which skills exist without any system prompt injection. The toolset exposes two tools:load_skillreturns a skill's full instructions on demand, andread_skill_filereads a file bundled with a skill (with path-traversal protection).read_skill_filereturns text files as strings, images asImageContent, and PDFs asFileContent, so anAgentbacked by a multimodal chat generator can read a skill's visual assets — for example a reference screenshot or a showcase PDF — directly.Custom stores can back
SkillToolsetwith any storage system (a database, a remote API, ...) by implementing theSkillStoreprotocol. -
Added tool result offloading for the
Agentvia the newhaystack.hooks.tool_result_offloadingmodule.ToolResultOffloadHookis anafter_toolhook that writes selected tool results to a store and replaces them in the conversation with a compact pointer (a reference plus a short preview). The next LLM call sees the reference instead of the full result, which helps manage the context window and is a step towards letting an Agent operate on offloaded results with follow-up tools (e.g. a bash tool that reads the referenced files).Configure what gets offloaded, per tool, via
offload_strategies:- Map a tool name, a tuple of tool names, or the wildcard
"*"to anOffloadPolicy. More specific keys win over"*"; a tool with no matching key is not offloaded. - Built-in policies:
AlwaysOffload,NeverOffload, andOffloadOverChars(offload when the result exceeds a character threshold). - For custom conditions, implement the
OffloadPolicyprotocol (it provides defaultto_dict/from_dict, so a stateless policy only needs ashould_offloadmethod).
Choose where results are stored:
FileSystemToolResultStorewrites results to the local file system.- Implement the
ToolResultStoreprotocol to target other backends. - The hook is stateless and can be shared across concurrent runs. In a multi-user server, pass an isolated store per run via the Agent's
hook_context(hook_context={RESULT_STORE_CONTEXT_KEY: per_request_store}) so concurrent users write to separate locations and never read each other's results.
What is offloaded:
- Only successful, text results are offloaded. Error results (including
before_toolhuman-in-the-loop rejections) are left in context. - Non-text results (image or file content) are left in context; supporting only text is a deliberate choice for now. A warning is logged when a non-text result has a matching offload policy.
- Each result is offloaded at most once.
from typing import Annotated from haystack.components.agents import Agent from haystack.components.generators.chat import OpenAIChatGenerator from haystack.dataclasses import ChatMessage from haystack.hooks.tool_result_offloading import ( FileSystemToolResultStore, OffloadOverChars, ToolResultOffloadHook, ) from haystack.tools import tool @tool def search(query: Annotated[str, "The search query"]) -> str: """Search the web and return the (potentially large) results.""" return f"... large result for {query} ..." offload_hook = ToolResultOffloadHook( store=FileSystemToolResultStore(root="tool_results"), offload_strategies={"*": OffloadOverChars(4000)}, ) agent = Agent( chat_generator=OpenAIChatGenerator(model="gpt-5.4-nano"), tools=[search], hooks={"after_tool": [offload_hook]}, ) agent.run(messages=[ChatMessage.from_user("Summarize today's tech news")])
- Map a tool name, a tuple of tool names, or the wildcard
-
Added
AsyncPipeline.stream(), a convenient way to stream LLM chunks from an async application, such as an API endpoint. It runs the pipeline and returns a handle that can be iterated withasync forto receiveStreamingChunkobjects from streaming-capable components as they are produced. After iteration ends,handle.resultholds the final pipeline output (the same dict returned byrun_async). By default every streaming-capable component is forwarded; passstreaming_componentsto stream only specific components. If the consumer abandons iteration, the underlying pipeline run is cancelled automatically; passcancel_on_abandon=Falseto let it run to completion.import asyncio from haystack import AsyncPipeline from haystack.components.builders import ChatPromptBuilder from haystack.components.generators.chat import OpenAIChatGenerator from haystack.dataclasses import ChatMessage pipe = AsyncPipeline() pipe.add_component( "prompt_builder", ChatPromptBuilder(template=[ChatMessage.from_user("Tell me about {{topic}}")]), ) pipe.add_component("llm", OpenAIChatGenerator()) pipe.connect("prompt_builder.prompt", "llm.messages") async def main(): handle = pipe.stream(data={"prompt_builder": {"topic": "Italy"}}) async for chunk in handle: print(chunk.content, end="", flush=True) return handle.result result = asyncio.run(main())
-
Components and pipelines now have a consistent resource lifecycle for acquiring and releasing resources such as HTTP clients and connections. In addition to the existing
warm_up, components can now implementwarm_up_asyncto acquire asynchronous resources, andclose/close_asyncto release them.Pipelinenow implements the same lifecycle methods. In addition to the existingwarm_up,warm_up_async()runs async warm-up on the serving event loop (falling back to the synchronouswarm_up), whileclose()/close_async()release synchronous and asynchronous resources respectively. Closing is always explicit; pipelines are never closed automatically after a run. -
Added a
sharedparameter toInMemoryDocumentStore. WhenTrue(the default, matching previous behavior), documents live in process-global storage shared across instances that use the sameindex. WhenFalse, documents are kept instance-local and freed when the instance is garbage collected. Useshared=Falsefor stores that are created frequently (for example, one per request in a served application) to avoid unbounded growth of the process-global storage. -
Pipeline.load/Pipeline.loads/Pipeline.from_dictnow accept two new keyword arguments:allowed_modules: list[str] | None— additional module patterns that may be imported during this deserialization call. Patterns are matched as prefixes ("mypkg"matchesmypkgand any submodule) or, if they contain*/?/[, usingfnmatchrules.unsafe: bool = False— when set toTrue, the deserialization allowlist is bypassed entirely. Only use this when you fully trust the source of the serialized data.
A new public helper,
haystack.core.serialization.allow_deserialization_module(pattern), extends the process-wide allowlist. TheHAYSTACK_DESERIALIZATION_ALLOWLISTenvironment variable (comma-separated patterns) is read at runtime on every deserialization call. -
Added
ConfirmationHook, abefore_toolAgent hook that applies Human-in-the-Loop confirmation strategies to pending tool calls. It confirms, modifies, or rejects the tool calls the model requested before they run by rewriting the conversation in the Agent'sState. Itsconfirmation_strategiesmapping accepts a single tool name, a tuple of tool names, or the wildcard"*"that applies to any tool without a more specific entry, so you can set a default strategy for all tools and override individual ones. -
Agent hooks may now declare an
allowed_hook_pointsattribute listing the hook points they support. TheAgentvalidates it at construction and raises aValueErrorif such a hook is registered under an unsupported hook point. For example,ConfirmationHooksetsallowed_hook_points = ("before_tool",), so registering it underbefore_llmoron_exitnow fails fast instead of silently doing nothing. Hooks that do not declare the attribute can still be registered under any hook point. -
haystack.logging.configure_logginggained three parameters:logger_nameto choose which logger(s) the formatting handler is attached to,propagateto control whether Haystack's loggers propagate their records to ancestor loggers, andconfigure_structlogto control whether the call configures the process-globalstructlog(taking over any existing configuration). Setpropagate=Falseto let Haystack fully own the output of its own logs and avoid duplicate log lines when the host application also configures the root logger.
⚡️ Enhancement Notes
-
Adds a
statusfield to each result produced byContextRelevanceEvaluatorandFaithfulnessEvaluator. The field isevaluatedfor valid results anderrorfor generation or parsing failures continued withraise_on_failure=False. -
Components that delegate to sub-components in their
run_asyncmethod (LLMMetadataExtractor,EmbeddingBasedDocumentSplitter,FallbackChatGenerator,TextEmbeddingRetriever,MultiRetriever,MultiQueryTextRetrieverandMultiQueryEmbeddingRetriever) now use a shared utility to run the sub-component. If the sub-component only implements a synchronousrunmethod, it is executed in a thread to avoid blocking the event loop. Notably,LLMMetadataExtractorandEmbeddingBasedDocumentSplitterpreviously fell back to their fully synchronousrunmethod in this case, which blocked the event loop for the duration of the call. -
Duplicate tool names are now detected at the start of each
Agentstep (before the chat generator call) instead of only at tool execution, so the error surfaces earlier and the LLM is never sent duplicate tool schemas. -
Pipeline.runnow acceptsbreak_pointandpipeline_snapshotat the same time. This enables a stepped debugging experience: run until a breakpoint, then resume from the resulting snapshot with a new breakpoint on the next component, pausing again for inspection at each step. The only rejected combination is abreak_pointtargeting the same component and visit count as the snapshot's break point, since it would trigger again before the resumed component runs and the pipeline could not make any progress. -
ChatMessage.from_dictnow also accepts the format Pydantic produces when it auto-serializesChatMessageas a plain dataclass (e.g. viamodel_dumpon a Pydantic model withChatMessagefields). In this format, content parts appear without their wrapping key (for example{"tool_name": "search", "arguments": {}}instead of{"tool_call": {"tool_name": "search", "arguments": {}}}) and are identified by their required field names. This makes it possible to round-tripChatMessageobjects that were implicitly serialized as part of larger Pydantic models. -
Removed duplicated class-import logic between
core/serialization.pyandutils/type_serialization.py, including the deserialization-allowlist and builtin-type-safety checks, by sharing a single internal implementation. No change to public behavior or to the security checks themselves. -
Agentnow exposes three new outputs that are populated automatically during a run and made available alongsidemessagesandlast_messagein the result dict:step_count(int): the number of steps the agent ran.token_usage(dict[str, Any]): aggregated token usage summed across every LLM call in the runtool_call_counts(dict[str, int]): number of times each tool was invoked, keyed by tool name.
These fields are added to
Agent.state_schemaautomatically so that tools registered viainputs_from_statecan read them mid-run. They are exposed only as Agent outputs so cannot be passed in as inputs toAgent.run/Agent.run_async. -
LLMnow exposes atoken_usageoutput alongsidemessagesandlast_message. BecauseLLMnever invokes tools and always runs exactly one step,step_countandtool_call_countsinherited fromAgentare not exposed onLLM. -
The
haystack.toolspackage now imports its heavier modules lazily. Importing the package no longer eagerly pulls inComponentTool,PipelineTool, andSearchableToolset(and their transitive dependencies such asPipelineandInMemoryDocumentStore); these are resolved on first access instead. This reduces import time when those tools are not used. All public names remain importable fromhaystack.toolsexactly as before. -
The
Agentnow emits onehaystack.agent.step.tooltracing span per tool call instead of a single span grouping every tool call in a step. Each span carries the tool's name and description as tags, plus the call arguments and result when content tracing is enabled. This matches the OpenTelemetry and Langfuse convention of a single tool call per span, so traces render one observation per tool invocation. -
LoggingTracernow serializes the log records it emits when a span closes, so a span's operation name and its tags stay contiguous even when spans are opened and closed from parallel threads (for example, theAgentrunning tool calls concurrently). -
Streaming-capable components (
OpenAIChatGenerator,OpenAIResponsesGenerator,HuggingFaceAPIChatGenerator,HuggingFaceLocalChatGenerator,Agent) now accept a synchronous streaming callback in asynchronous contexts (run_async). A warning is logged because the callback runs synchronously on the event loop and may block it, but the run proceeds. Async callbacks are still preferred for performance.import asyncio from haystack.components.generators.chat import OpenAIChatGenerator from haystack.dataclasses import ChatMessage, StreamingChunk def print_chunk(chunk: StreamingChunk) -> None: print(chunk.content, end="", flush=True) async def main(): llm = OpenAIChatGenerator() # previously this raised; now it logs a warning and streams as expected await llm.run_async( [ChatMessage.from_user("Tell me about Italy")], streaming_callback=print_chunk, ) asyncio.run(main())
-
Simplified the pipeline scheduler by removing the
DEFER_LASTcomponent priority. Components that previously gotDEFER_LAST(those with unresolved lazy-variadic sockets) now share theDEFERpriority and are tie-broken via topological order, matching the behavior of all other deferred components. This should not materially change how components are scheduled in a pipeline. -
ToolCallResultcan now carryFileContentparts, in addition toTextContentandImageContent. AToolcan return a file (such as a PDF) as its result so a multimodal chat generator that supports file inputs (for exampleOpenAIResponsesChatGenerator) passes it straight to the model. Combine this with a tool'soutputs_to_string={"raw_result": True}to keep the content parts intact instead of converting them to a string. -
Toolsetgained two methods:get_selectable_tools()returns every tool available for name-based selection (ignoring any active selection restriction, e.g. the one used bySearchableToolset), andspawn()returns an isolated, run-scoped copy of the Toolset. Subclasses with additional run-scoped state can overridespawn(). -
SearchableToolsetnow exposes its full catalog of tools viaSearchableToolset.get_selectable_tools(). -
Toolset.warm_up()is now idempotent: warming up a toolset more than once no longer re-warms its tools. CallingToolset.add()after the toolset has been warmed up now warms up the newly added tool (or toolset) immediately, so it is ready to use without an extrawarm_up()call. -
Removed the use of
Pipeline._run_componentinsideAgent.runandAgent.run_async. The chat generator and tool invoker are now invoked directly via theirrun/run_asyncmethods, decoupling the Agent from the Pipeline internals.
🔒 Security Notes
- Fixed a deserialization-allowlist bypass that could allow arbitrary code execution when loading an untrusted pipeline (
Pipeline.load/Pipeline.loads/Pipeline.from_dict). The module allowlist was enforced against a string prefix of the serialized handle, while the callable/class resolvers walked attributes from the deepest importable module. Because an allowlisted package can expose another module as an attribute (e.g. a module-scopeimport osmakeshaystack.utils.auth.osthe standard-libraryosmodule), a handle such ashaystack.utils.auth.os.systempassed the allowlist and resolved toos.system. The allowlist is now enforced against the module each object is actually resolved from — every module hop during attribute resolution is re-checked, and the resolved callable's/class's real module must be on the allowlist. Legitimate handles (including harmless builtins such asbuiltins.print) continue to resolve. - Loading a pipeline from YAML used to dynamically import any class referenced in the file, which made a crafted YAML capable of causing arbitrary classes to be imported and instantiated by
Pipeline.from_dict. Deserialization is now gated by an allowlist of trusted module namespaces (see the upgrade note for how to extend it). In addition,default_from_dictnow refuses to recurse into nested{"type": "..."}dictionaries keyed by parameter names that the parent class does not actually accept — blocking attempts to smuggle untrusted classes into unused parameter slots. - Dangerous builtins such as
eval,exec,compile,__import__,open, andgetattrcan no longer be resolved during deserialization, even thoughbuiltinsis on the default allowlist. Attempting to deserialize one as a callable, a type annotation, or a nested{"type": ...}class reference raises aDeserializationError. The block can be bypassed withunsafe=Truewhen the source of the serialized data is fully trusted.
🐛 Bug Fixes
-
Agentnow correctly executes tools supplied only at runtime viarun(tools=...)/run_async(tools=...)when no tools were provided at initialization. Previously the run stopped after the first LLM call because the step guard checked the init-time tools instead of the tools resolved for the run. -
Initializing an
Agentno longer warms up aSearchableToolset. Previously construction could iterate the tools, andSearchableToolsetwarms itself up when iterated, so it was warmed up too early. Its warm-up now happens only viaAgent.warm_up()or at run time. -
The
haystack.agent.runtracing span'shaystack.agent.toolstag now reflects the tools actually used for the run. Previously it always reported the init-time tools, so it was inconsistent with the run when tools were overridden at runtime viarun(tools=...)/run_async(tools=...). -
Fixed a bug in
Agent(and the underlying tool invocation) where parallel tool calls in a single step did not respect read/write dependencies onState. Tool arguments were prepared once, up front, from the initial state, so a tool reading aStatekey viainputs_from_statenever observed a write made by another tool in the same step viaoutputs_to_state. For example, two calls to a retrieval tool that both reads and writes thedocumentskey would each see the original documents instead of the second call seeing the first call's results.Tool calls within a step are now scheduled into batches based on their
inputs_from_stateandoutputs_to_statemappings. Calls that only read, or that touch disjoint keys, still run in parallel; a call that reads a key another call writes is run in a later batch, after that write has been merged intoState, with its arguments re-prepared so it sees the updated value. Tools that receive the liveStateobject are treated as reading and writing every key and run on their own. The order the LLM requested the calls in no longer affects correctness. -
AnswerBuilder now treats a
[0]document reference as out of range instead of silently attaching the last document. References are 1-based, so[0]produced a negative index that bypassed the out-of-range guard, marking an unrelated document asreferencedwith a wrongsource_index. The same applied to expanded reference ranges starting at 0 (e.g.[0-1]). -
Fixed a task leak in
AsyncPipelinewhen running components concurrently. Previously, if one component raised an error while sibling components were still running, those in-flight tasks were neither awaited nor cancelled and kept running in the background until the event loop was torn down. They are now cancelled and drained before the original error is re-raised. The same cleanup now also applies when iteration ofrun_async_generatoris stopped early (e.g. the consumer breaks out of the loop and closes the generator) or the run is cancelled: any tasks still in flight are cancelled instead of leaking. Note that cancellation only interrupts components that run natively async. Sync components are offloaded to a worker thread, which cannot be interrupted and runs to completion in the background; its outputs are discarded, so pipeline state stays consistent, but the component's side effects still complete. -
ComponentToolno longer adds a top-leveldescriptionkey to the generatedparametersJSON schema. The wrapped component's docstring was emitted inside the parameters schema, where LLM providers (OpenAI, Anthropic, Google) do not use it, and for tools wrapping aSuperComponentit produced a confusing auto-generated summary. The parameters schema now only carries per-parameter descriptions, matching plainTooland function-based tools. The tool-leveldescriptionis unchanged. -
Fixed
CSVToDocumentinconversion_mode="row"crashing the entire batch when a CSV row has more fields than the header (ragged rows, e.g. an unquoted comma inside a value). Surplus values are now stored under an explicitextra_columnsmetadata key instead of theNonekey, which previously broke Document id generation and discarded results from all sources. -
Declare the
metaoutput ofLLMEvaluator,ContextRelevanceEvaluator, andFaithfulnessEvaluator, and theindividual_scoresoutput ofContextRelevanceEvaluator. These outputs were already returned but not declared, so they could not be connected to other components in a Pipeline. -
Fixed
DocumentRecallEvaluatorreturning wrong scores when the configureddocument_comparison_fieldis missing or empty on some documents. Those documents were considered a match for each other, so unrelated documents could count as correctly retrieved. They are now ignored when computing the score. -
Improved the validation and compatibility layer of
Document:- since Haystack 2.x,
embeddingis a list of floats, while it was a NumPy array in 1.x, so a NumPyembeddingis converted to a list for backward compatibility. The conversion only happened whenembeddingwas passed as a keyword argument: passed positionally, the array was silently left as is, potentially causing downstream issues. contenttype was validated only when passed as a keyword argument.contenttype validation was also skipped when the value was falsy, such as0or[], and failed later with an unrelated error.
- since Haystack 2.x,
-
Fix
FilterPolicy.MERGEinInMemoryBM25RetrieverandInMemoryEmbeddingRetrieverso initialization filters are combined with runtime comparison filters instead of being silently overwritten. -
Fixed a
TypeErrorraised when filtering documents with the ordering operators (>,>=,<,<=) where both the filter value and the document meta value aredatetimeobjects but one is timezone-aware and the other is naive. The naive/aware reconciliation is now applied to any pair ofdatetimeoperands, matching the behavior already used for ISO 8601 string dates. -
Fixed
GeneratedAnswer.from_dictandExtractedAnswer.from_dictmutating their input dictionary in place. They replaced nested values (documents,document, offsets andmeta["all_messages"]) directly inside the passed dictionary, so deserializing the same dictionary a second time received already-parsed objects and raised anAttributeError. Both methods now copy their input before deserializing and are side-effect free. -
Fixed
component_to_dictserialization validation not detecting non-string keys in nested dictionaries. The check only inspected the top-level dictionary, so a non-string key inside a nestedinit_parametersvalue passed validation and could later fail during marshalling; nested dictionaries are now validated too. -
Fixed
CountUniqueMetadataByFilterTest.test_count_unique_metadata_by_filter_with_multiple_filtersin the document store testing mixins: it calledcount_documents_by_filterinstead ofcount_unique_metadata_by_filter. The test now exercises the intended method with a compound filter, matching its async counterpart. -
Fixed
_deepcopy_with_exceptionscrashing when a value is anamedtuple(or anytyping.NamedTuple). The list/tuple/set branch rebuilt the container withtype(obj)(<generator>), but a namedtuple's__new__expects its fields as positional arguments rather than a single iterable, so copying a component input or parameter that contained a namedtuple raised aTypeError. Namedtuples are now rebuilt by unpacking their deep-copied fields. -
Fixed
default_from_dict(and thereforecomponent_from_dict) mutating the caller'sdatadictionary in place when deserializing nested objects such asSecretorComponentDevice. The serialized sub-dictionaries were being replaced by their deserialized instances inside the input dict; the input is now left unchanged, so the samedatacan be safely deserialized more than once. -
Fixed
deserialize_callablefailing for functions decorated with@toolwhen the decorated function is asynchronous. Such tools store the callable inasync_functionwithfunctionset toNone, which previously caused deserialization to raise aDeserializationError. The underlying coroutine function is now resolved correctly. -
Fixed
DocumentCleanerwiping the middle page of a 3-page document whenremove_repeated_substrings=True. Header/footer detection compares the pages between the first and last, which is a single page for a 3-page document; its own longest ngram was then treated as a repeated header/footer and removed. Detection now requires at least two pages to compare, so unique content is preserved while genuine repeated headers/footers are still removed. -
Fixed
DocumentTypeRoutermisrouting documents whose MIME type contains a regex metacharacter. A declared type such as the standard IANAimage/svg+xmlwas compiled as a regex, so the+was treated as a quantifier and the document fell intounclassifiedinstead of its own bucket. Declared MIME types are now matched by exact equality first, falling back to regex matching so patterns likeaudio/.*keep working. -
EmbeddingBasedDocumentSplitternow setssplit_idx_startin the metadata of every output chunk, consistent withDocumentSplitterandRecursiveDocumentSplitter. Previously the field was absent, causing aKeyErrorin any downstream code that reads character offsets from splitter output (e.g. highlighting, provenance tracking, overlap recovery). -
Fixed
FileToFileContentsharing a singleextradictionary across all producedFileContentobjects whenextrais passed as one dict (or left asNone). EachFileContentnow gets its own copy, so mutating one file'sextrano longer leaks into the others. -
Fixed a bug where configuring an
Agentconfirmation strategy could make a tool run with stale arguments. HITL confirmation prepared and baked each tool's final arguments up front (injectinginputs_from_statevalues from the state at the start of the tool calling step), which defeated the per-batch argument preparation in tool execution: a tool that reads a state key written by another tool in the same step would run with the pre-step value instead of the freshly produced one. HITL confirmation now operates on the model-requested arguments and leaves state injection to tool execution, so dependent tools again receive the correct values. -
Fixed a
KeyErrorraised by the human-in-the-loop confirmation strategies when a chat model hallucinated a tool call whose name is not in the agent's tools. Such tool calls now bypass confirmation and pass through unchanged, so the tool-calling code reports the unknown tool uniformly viaToolNotFoundException(respectingraise_on_failure) instead of crashing. -
Fixed
JSONConvertercrashing on a malformed JSON source when it is configured withcontent_keyand nojq_schema. The non-jq branch calledjson.loadswithout a guard, so a single invalid.jsonfile raised an unhandledJSONDecodeErrorand aborted the whole run. Invalid JSON is now caught, logged, and skipped, matching the behavior of thejq_schemapath. -
Fixed
MarkdownHeaderSplitterdropping content whenkeep_headers=False. The secondary split re-stripped a leading header with a non-anchored search, which matched the first#line anywhere in the body (an embedded lower-level header, or a#comment inside a fenced code block) and discarded everything before it. The match is now anchored to the start of the content, so only a genuine leading header is stripped. -
Fix the
max_runs_per_componentlimit onPipelineandAsyncPipelineso that a component is now stopped when its number of runs reachesmax_runs_per_componentrather than when it strictly exceeds it. Previously, a component could run one more time than the configured limit beforePipelineMaxComponentRunswas raised. This only affected pipelines with loops. The check is also skipped forBLOCKEDcomponents, since they cannot run anyway and should not trigger the error. -
Fixed
ChatMessage.from_openai_dict_formatcrashing on zero-argument tool calls. OpenAI-compatible servers (vLLM, llama.cpp, Ollama, LM Studio, ...) may send an empty string or omit theargumentsfield for a tool call that takes no arguments; this previously raisedjson.JSONDecodeErrororKeyError. Such tool calls are now parsed into aToolCallwith emptyarguments. -
request_with_retryandasync_request_with_retrynow honor a customtimeouton every retry attempt. Previously thetimeoutwas consumed on the first attempt and any subsequent retry silently fell back to the default of 10 seconds. -
SearchableToolsetnow builds its internal BM25InMemoryDocumentStorewithshared=False, so its search index is freed together with the toolset instead of accumulating inInMemoryDocumentStore's process-global storage. Previously, repeatedly constructing aSearchableToolset(for example, building anAgentper request in a served application) leaked one storage partition per instance for the lifetime of the process. -
Document.idis now deterministic regardless of the insertion order of keys inmeta. Previously the hash was built fromdict's repr, which reflects insertion order, so two documents with the same content and the samemetacould get different IDs depending on how themetadict was constructed. This silently brokeDuplicatePolicy.SKIP/FAILand any cache or dedup table keyed on the document ID whenever upstream code producedmetain different orders. -
Fixed
MetaFieldGroupingRankercrashing with an uncaughtTypeErrorwhen a group containedsort_docs_byvalues of mutually non-comparable types (for example anintand astr). The sort is now wrapped in atry/except, so in that case the group's original insertion order is kept and a warning is logged, mirroring the behavior ofMetaFieldRanker. -
Remove zero-frequency vocabulary entries after deleting documents from
InMemoryDocumentStoreso previous document history no longer changes BM25Okapi scores for the active corpus. -
haystack.logging.configure_loggingno longer freezes the log level at import time. Previously the level was captured once when the function ran (atimport haystack), so a log level set later by the application was ignored forstructlog-native loggers. The effective level is now read on every log call. -
Fixed the logger in
haystack.utils.requests_utilsbeing named after the module's file path instead ofhaystack.utils.requests_utils, which kept its records outside thehaystacklogger namespace. -
haystack.logging.getLoggeris now idempotent. Previously, calling it more than once for the same logger name wrapped the already-wrapped logger methods again, which caused the log message to be run throughstr.formatonce per call. As a result a field value containing{...}could be re-interpolated and pull in the value of another field. Each logger is now patched only once. -
The patched
findCallerused to determine the source of a log entry no longer prints to stdout and no longer masks errors with a misleadingNameError, matching structlog's ownfindCallerimplementation. -
SearchableToolsetnow raises aValueErrorduringwarm_up()when its catalog contains tools with duplicate names. Previously duplicates were silently collapsed, which could cause a search hit to resolve to the wrong tool. -
SearchableToolsetserialization now preserves the catalog shape: a catalog passed as a singleToolsetround-trips back to aToolset(previously it was always rebuilt as a list). Serialization now reusesserialize_tools_or_toolset/deserialize_tools_or_toolset_inplace. -
Runtime tool-name selection via
Agent.run(tools=["tool_a", "tool_b"])now resolves correctly when aSearchableToolsetis configured. Previously theSearchableToolsetwas flattened into a single-item list (just its search tool), which broke its search and lazy-loading behavior. -
A
Toolsetis no longer mutated in place during anAgentrun. Each run now operates on an isolated per-run copy of the configuredToolset(via the newToolset.spawn()method). This makes concurrent runs that share the sameToolsetinstance safe: per-run state such as the active tool-name selection, and aSearchableToolset's discovered tools, can no longer leak or collide across runs. -
Agentnow warms up tools and toolsets passed at runtime viaAgent.run(tools=...)/Agent.run_async(tools=...), not only the tools provided at initialization. -
Combining two toolsets with
+produces an internal_ToolsetWrapperthat can now be serialized and deserialized correctly.to_dict()/from_dict()preserve each wrapped toolset (via its ownto_dict()), and the wrapper'swarm_up()is now idempotent.
💙 Big thank you to everyone who contributed to this release!
@Aarkin7, @Amnah199, @anakin87, @bilgeyucel, @bogdankostic, @camgrimsec, @charliesheh, @chuenchen309, @davidsbatista, @eeshsaxena, @greymoth-jp, @hoangsonww, @hxaxd, @immuhammadfurqan, @jialiuyang, @jinhaosong-source, @julian-risch, @milljer, @mustafakhokhar, @omborda2002, @Osamaali313, @Otis0408, @RajanChavada, @rautaditya2606, @SeCuReDmE-main-dev, @sjrl, @tstadel, @Vedant-Agarwal, @vidigoat