Repository navigation
Releases: deepset-ai/haystack
Release list
v3.4.1
🔒 Security Notes
- Haystack now requires
certifi>=2026.10.19.certifiis installed transitively throughhttpxandposthog; earlier versions trust eMudhra/emSign certificate authorities whose private keys were compromised (GHSA-6q48-6c7w-9gw2). If you pincertifi, raise the pin to at least 2026.10.19.
v3.4.1-rc2
v3.4.1-rc2
v3.4.1-rc1
Release Notes
v3.4.0
⭐️ Highlights
🐍 Python 3.15 Support, Python 3.10 Dropped
Haystack now officially supports Python 3.15. Python 3.10 reaches end of life in October 2026, so the minimum supported version is now 3.11.
📦 ToolResultOffloadCompactor: Compact Agent Context Without Losing Tool Results
The experimental ToolResultOffloadCompactor is a new compaction strategy for CompactionHook. When compaction is triggered, it doesn't drop or summarize older tool results. It writes them to a ToolResultStore and leaves a reference in their place. The Agent can read the full output back on demand with a read tool scoped to the same store. Fresh results stay in context until compaction kicks in, so short runs pay nothing extra.
from typing import Annotated
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIResponsesChatGenerator
from haystack.hooks.compaction import CompactionHook, ToolResultOffloadCompactor
from haystack.hooks.tool_result_offloading import FileSystemToolResultStore
from haystack.tools import tool
store = FileSystemToolResultStore(root="tool_results")
@tool
def read_offloaded_result(path: Annotated[str, "Path of an offloaded tool result"]) -> str:
"""Read back the full content of an offloaded tool result."""
content = store.read(path)
if isinstance(content, bytes):
return f"'{path}' holds {len(content)} bytes of binary content and cannot be read as text."
return content
agent = Agent(
chat_generator=OpenAIResponsesChatGenerator(model="gpt-5.4-nano"),
tools=[web_search, read_offloaded_result],
hooks={"before_llm": [CompactionHook(compactor=ToolResultOffloadCompactor(store=store), context_window=400_000)]},
)⬆️ Upgrade Notes
-
The
page_numbermetadata field now means the same thing in every splitter: the page the chunk's text starts on. Two cases change:RecursiveDocumentSplitterreported the page a chunk ended on. A chunk spanning a page break now reports the page it starts on, matchingDocumentSplitterandMarkdownHeaderSplitter.DocumentSplitter,MarkdownHeaderSplitterandEmbeddingBasedDocumentSplitterreported the previous page for a chunk that began with one or more form feeds (\f), even though all of the chunk's text was on the following page. Such chunks now report the page their text is on. A chunk made up entirely of page breaks is an empty page and keeps the page it starts on, so blank pages emitted bysplit_by="page"still get their own number.
If you display
page_numberto users or store it alongside embeddings, re-index documents that contain form feeds to pick up the corrected values. -
RecursiveDocumentSplitterwithsplit_unit="word"andsplit_overlapgreater than 0 now returns chunks copied from the document as they are. Previously it rebuilt them from their words joined by single spaces, which added spaces the document does not have, collapsed line breaks and repeated spaces, and could drop form feeds (\f). The overlap also counts words the waysplit_lengthdoes, as text separated by spaces, so where words are separated only by line or page breaks, chunks can end at different words than before. Re-index documents split this way if you need their chunks to match the source text. -
RecursiveDocumentSplitternow keeps the whitespace between sentences whensentence_splitter_paramssetskeep_white_spacestoFalse. That is the sentence tokenizer's default, so this applies wheneversentence_splitter_paramsis passed without"keep_white_spaces": True. The tokenizer leaves that whitespace out, and the chunks used to glue their sentences together, as in"One two.Three four.": the glued words counted as one towardsplit_length, page breaks (\f) between sentences were lost, andsplit_idx_startandpage_numberfell behind the chunk's real start. Chunks are now copied from the document whateverkeep_white_spacesis set to; it only changes the rules the tokenizer uses to decide where a sentence ends. Re-index documents split with this setting to pick up the corrected chunks. -
Dropped support for Python 3.10 since it reaches End of Life (EOL) in October 2026. The minimum supported Python version is now 3.11. If you are on Python 3.10, upgrade your Python version before upgrading Haystack.
-
FilterPolicy.MERGEnow intersects initialization and runtime logical filters when both useORorNOT. Callers that relied on the previous broader result should construct a single explicitORfilter containing all alternatives and pass it withFilterPolicy.REPLACEinstead. -
ChatMessage.from_openai_dict_formatnow raises aValueErrorfor content parts it can't convert: images referenced by a URL such ashttps://example.com/image.png(only images embedded as base64 data URLs, likedata:image/png;base64,..., are supported), files referenced byfile_id, and other part types such asinput_audio. Previously, such messages were accepted because the whole list was stored as a single text, andChatMessage.to_openai_dict_formatsent that list back unchanged, so forwarding them to OpenAI worked by accident. You're affected if the OpenAI messages you convert contain any of these parts. To keep sending images by URL, download them and build the message yourself, for exampleChatMessage.from_user(content_parts=["Describe this image", ImageContent.from_url(url)]). Send files inline as base64file_datainstead of referencing them byfile_id. Extra keys on content parts, such ascache_control, are no longer passed through, so prompt caching through OpenRouter or LiteLLM stops for these messages. -
HTMLToDocumentnow defaults toencoding=None, delegating HTML encoding detection to Trafilatura. Setencoding="utf-8"to retain explicit UTF-8 decoding. -
Previously,
HTMLToDocumentused theencodingvalue in a source ByteStream's metadata to decode its content, overriding the converter's encoding setting. This metadata no longer affects decoding. If you relied on it, pass the encoding to the converter instead, for exampleHTMLToDocument(encoding="latin-1"). -
The default model for
OpenAITextEmbedder,OpenAIDocumentEmbedder,AzureOpenAITextEmbedder, andAzureOpenAIDocumentEmbedderhas been changed fromtext-embedding-ada-002totext-embedding-3-small.text-embedding-3-smallis roughly 5x cheaper per token than the previous generationtext-embedding-ada-002and scores higher on the MTEB benchmark (see OpenAI's announcement).To preserve previous behavior, pass
model="text-embedding-ada-002"explicitly (or, for the Azure variants,azure_deployment="text-embedding-ada-002").Note for Azure users:
azure_deploymentrefers to the deployment name in your Azure OpenAI resource, not a model identifier. If you do not already have atext-embedding-3-smalldeployment in Azure, you will need to create one or pass the name of an existing deployment explicitly.Embeddings generated with the new default model are not compatible with embeddings produced by
text-embedding-ada-002. If you have a document store populated with previously-generated embeddings, either keep using the old model explicitly or re-embed your corpus. -
The base
Tool.warm_up()andToolset.warm_up()methods were removed. OpenAI/Azure Chat Generators no longer warm up tools.If you use tools through an Agent, no changes are needed. For manual preparation, use
warm_up_toolsorwarm_up_tools_async. Lazy toolsets such asMCPToolsetmust be warmed up before passing them to a Chat Generator.
🚀 New Features
-
Added the experimental
ToolResultOffloadCompactorforCompactionHook. Once compaction is triggered, it writes older tool results to aToolResultStoreand leaves a reference in their place, so the Agent can read the full output back with a read tool you scope to the same store. Fresh results stay in context until then.from typing import Annotated from haystack.components.agents import Agent from haystack.components.generators.chat import OpenAIResponsesChatGenerator from haystack.hooks.compaction import CompactionHook, ToolResultOffloadCompactor from haystack.hooks.tool_result_offloading import FileSystemToolResultStore from haystack.tools import tool store = FileSystemToolResultStore(root="tool_results") @tool def read_offloaded_result(path: Annotated[str, "Path of an offloaded tool result"]) -> str: """Read back the full content of an offloaded tool result.""" content = store.read(path) if isinstance(content, bytes): return f"'{path}' holds {len(content)} bytes of binary content and cannot be read as text." return content # web_search stands in for your own tool that returns long results. hook = CompactionHook(compactor=ToolResultOffloadCompactor(store=store), context_window=400_000) agent = Agent( chat_generator=OpenAIResponsesChatGenerator(model="gpt-5.4-nano"), tools=[web_search, read_offloaded_result], hooks={"before_llm": [hook]}, )
⚡️ Enhancement Notes
-
When an
Agentruns inside aPipeline, the anonymous pipeline telemetry event now also reports the chat generator class and its tools: the number of tools, the class and name of each tool, the component wrapped by eachComponentTool, and the classes of anyToolsetobjects. -
Haystack now officially supports Python 3.15.
-
Removed the
Sockets.__getattribute__override. It looked up a_socketsattribute that no instance has, so on every attribute access it r...
v3.4.0-rc1
v3.4.0-rc1
v3.3.0
⭐️ Highlight
⚡️ Faster SentenceWindowRetriever
SentenceWindowRetriever now queries the Document Store once per run or run_async call instead of once per retrieved document. Fewer calls mean lower latency and less load on the Document Store and outputs have not changed, so existing pipelines get faster without code changes.
⬆️ Upgrade Notes
InMemoryDocumentStore.bm25_retrievalandInMemoryBM25RetrieverwithBM25L(the default) orBM25Plusnow return only documents that contain at least one query term, with or withoutscale_score. Previously, documents without any query term were returned with a positive score and filled uptop_k. As a result, retrieval can now return fewer thantop_kdocuments, or none at all. Thedeltalower bound now applies only to query terms that occur in the document, as in the original BM25L/BM25+ definitions. So scores of matching documents are lower than before whenever the query has terms that a document does not contain. If you filter results with a fixed score threshold, re-check the threshold.BM25Okapiis not affected.
⚡️ Enhancement Notes
TextCleaner.runnow raises a clearTypeErrorwhentextsis not a list or any element is not astr, instead of failing later or producing unexpected results.
🔒 Security Notes
- Haystack now requires
anyio>=4.14.2.anyiois installed transitively throughhttpxandopenai; versions before 4.14.2 are affected by CVE-2026-63374 (GHSA-82r6-8w77-94w6).
🐛 Bug Fixes
-
Fixed BM25 tokenization in
InMemoryDocumentStorefor Chinese, Japanese and Korean text. The defaultbm25_tokenization_regexnow splits CJK characters into one token each, so bare-term queries can match words inside longer unspaced runs. Text is also NFC-normalized before tokenization, so composed and decomposed spellings produce the same tokens. -
Fix
Pipeline.connect()raisingTypeError: object of type 'ellipsis' has no len()when one of the sockets is annotated withCallable[..., T]. ACallable[..., T]now matches callables with any parameters, in both directions, as long as the return types are compatible. -
Fixed
ChatPromptBuilderdropping content parts when the template is a list ofChatMessageobjects. Only the firstTextContentpart was rendered, and every other part - additional texts, images, files -was removed from the rendered prompt without a warning. Template variables used in those dropped parts were not detected either, so they were not exposed as inputs. Now allTextContentparts are rendered and the remaining content parts are passed through unchanged. -
Fixed
CompactionHook.close()andclose_async()to release the token counter's resources as well as the compactor's. Previously, resources such asOpenAITokenCounter's HTTP client remained open after the hook or Agent was closed. -
ConfirmationHookno longer drops the messages that come after the last user or tool message when it rewrites the conversation, such as a system message added by another hook or an assistant answer followed by anon_exitreminder. Before, those messages were removed from the history the Agent sends to the model. -
DocumentToImageContentno longer raises aKeyErrorfor the whole batch when a document points to a PDF page that cannot be converted, because the page is out of range or the PDF cannot be read. That document now getsNoneinimage_contents, like other invalid documents, and the rest of the batch is still converted. The logged warnings now include the path of the PDF file. -
Fixed
DOCXToDocumentbreaking a Markdown table when a cell contains a pipe or spans several paragraphs. A pipe was emitted as a column separator, and a cell's line break ended the row in the middle of it. Pipes are now escaped and line breaks inside a cell are collapsed to a space. Thecsvtable format is unchanged: it already kept such cells intact by quoting them. -
Fixed
DOCXToDocumentdropping hyperlink addresses inside tables. Withlink_formatset tomarkdownorplain, links in table cells were written as their display text only, while links in body paragraphs kept their address. Links in table cells are now formatted the same way, in both themarkdownandcsvtable formats. The defaultlink_format="none"output is unchanged. -
Fixed
ConditionalRouter.from_dictmutating the caller'sroutesdata in place: serializedoutput_typestrings were deserialized directly inside the caller's dictionaries, so reusing the same serialized pipeline dict afterwards yielded already-deserialized type objects.from_dictnow works on a copy and the caller's data is left untouched.BranchJoiner.from_dictreceived the same treatment. -
LinkContentFetcherno longer adds itstimeoutandfollow_redirectsdefaults to theclient_kwargsdictionary passed by the caller. The dictionary is now copied before the defaults are applied, so reusing one HTTP client configuration across components no longer leaks these defaults. -
Fix
MetaFieldRankerto return no documents whenmissing_meta="drop"and all documents lack the ranking field or have aNonevalue. -
MetaFieldRankernow treats a Document whosemeta_fieldvalue isNonethe same as a Document that is missing the field, applying themissing_metasetting to it. Previously a singleNonevalue made sorting fail, so the ranker logged a warning and returned all Documents in their original order, ignoringmissing_meta="drop"as well. -
MockTextEmbedderandMockDocumentEmbeddernow accept non-positivedimensionvalues whenembeddingorembedding_fnis provided, matching the documented behavior. The default deterministic embedding mode still requires a positivedimension. -
ChatMessage.from_openai_dict_formatnow accepts tool-callargumentsthat are already a dictionary instead of raising aTypeError. Some OpenAI-compatible servers send a parsed object rather than a JSON string. -
MetaFieldGroupingRankernow treats agroup_byorsubgroup_byvalue ofNoneas missing, the same way it already treatssort_docs_by. Documents withNonego to the end with the other ungrouped documents, instead of forming a group named"None"that also absorbed documents whose value was the string"None". -
Fixed
JSONConverterfailing withValueErrorwhencontent_keycontains numeric or boolean scalar values. These values are now converted to strings before creating theDocument, whilenullvalues remain unchanged. -
Fixed
SentenceSplitterlosing the whitespace between two sentences when the first one ends with a closing quote, as inHe said "Hi." Bye.. Those characters were missing from the chunk text and shifted thesplit_idx_startoffset of every following chunk, so chunks could no longer be mapped back onto the original text. This affects all components that split on sentences, such asDocumentSplitter,RecursiveDocumentSplitter,MarkdownHeaderSplitterandEmbeddingBasedDocumentSplitter. Chunk boundaries change for text that contains quoted sentences, so re-indexing an existing corpus produces different chunks than before. -
Fixed
LLMMetadataExtractorincorrectly treating a chat generator output that carries its ownerrorfield as a failed LLM call, which sent every document tofailed_documentswithmetadata_extraction_errorset toNone. Such documents are now processed normally. -
Retrievers now handle
top_kconsistently. A negativetop_kpassed at runtime toInMemoryBM25Retriever,InMemoryEmbeddingRetrieverorMultiRetriever(top_kandtop_k_per_retriever) now raises aValueError. Previously it was applied as a negative slice, silently dropping the last documents. A runtimetop_k=0returns no documents.MultiRetrievernow also validatestop_kandtop_k_per_retrieverat initialization, raising aValueErrorwhen they are set and not greater than 0, matching the in-memory retrievers.Nonestill means no limit. -
Fixed
Pipeline.run(),Pipeline.run_async(), andPipeline.stream()treating a flat input with a dictionary value as a component name. For example,{"payload": {"x": 1}}now reaches components with apayloadinput without requiring the component name in the input data. -
Pipeline.run_asyncnow lets aBreakpointExceptionorPipelineRuntimeErrorraised by a nested component propagate unchanged, instead of wrapping it in anotherPipelineRuntimeError. This matches the synchronousPipeline.runand preserves the original error context (for example an agent snapshot) when a component internally runs a pipeline. -
XLSXToDocumentnow escapes pipe characters and replaces in-cell line breaks with spaces in the default Markdown pipe table output. This keeps cell content from being interpreted as additional table columns or rows.
💙 Big thank you to everyone who contributed to this release!
@alanhuangyoo, @anakin87, @bilgeyucel, @carey-bk, @Cha-Imaa, @chrikrah, @dakjdakd, @gauravch-code, @Harsh23Kashyap, @Jayanth-reflex, @JbravoI, @julian-risch, @L4XB, @Lesereingrape, @lets-order-some-fries, @mnm-matin, @MohammadHijjawi97, @nanhanq1, @pcbeingused333, @PeterSmith0127-lcm, @Rainmemery, @sclfcz, @serhiizghama, @shivsin25, @ShousenZHANG, @simpleqt, @winklemad
v3.3.0-rc1
v3.3.0-rc1
v3.2.0
⭐️ Highlights
🧠 SummarizationCompactor: Compact Agent Context Without Losing It
The experimental SummarizationCompactor is a new compaction strategy for CompactionHook. It replaces older turns and Agent steps with LLM-generated summaries. Long-running Agents keep a condensed record of earlier goals, decisions, and open work. It summarizes as little as needed to reach the target size, starting with the oldest historical turns and only then moving to the current task's older steps. The min_keep_steps newest steps always stay as they are. You can use a small, cheap model to write the summaries.
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIResponsesChatGenerator
from haystack.hooks.compaction import CompactionHook, SummarizationCompactor
compaction_hook = CompactionHook(
compactor=SummarizationCompactor(
chat_generator=OpenAIResponsesChatGenerator(model="gpt-5.4-nano"),
min_keep_steps=2,
),
context_window=400_000,
compact_at=0.7,
compact_to=0.4,
)
agent = Agent(
chat_generator=OpenAIResponsesChatGenerator(model="gpt-5.4"),
tools=[web_search],
hooks={"before_llm": [compaction_hook]},
)💰 Token Budgets and Clean Stops for Agent Runs
The experimental TokenBudgetHook caps how many tokens an Agent run can spend. Once the budget is used up, the Agent stops before its next LLM call, sets exit_reason to "token_budget_exceeded", and returns the messages collected so far. Set add_final_message=True to add an assistant message explaining why the run ended.
The hook is built on the new stop_run state key, which any hook can set to end a run cleanly with a custom reason instead of raising an error. For example, you can stop after too many calls to one tool:
from haystack.hooks import hook
from haystack.hooks.budget import TokenBudgetHook
@hook
def limit_searches(state):
if state.get("tool_call_counts", {}).get("web_search", 0) >= 5:
state.set("stop_run", "search_limit_reached")
agent = Agent(
chat_generator=OpenAIChatGenerator(model="gpt-5.4-nano"),
tools=[web_search],
hooks={"before_llm": [TokenBudgetHook(max_total_tokens=100_000), limit_searches]},
)
result = agent.run(messages)
print(result["exit_reason"]) # "text", "token_budget_exceeded", or "search_limit_reached"🔗 Fluent Pipeline Building
Building a pipeline takes less code with Haystack version 3.2: Pipeline.add_components() adds several components in one call and Pipeline.connect_many() creates several connections in one call. Both add_component() and add_components() now return the pipeline, so you can chain the calls.
from haystack import Pipeline
pipeline = (
Pipeline()
.add_components({"retriever": retriever, "prompt_builder": prompt_builder, "llm": llm})
.connect_many([("retriever", "prompt_builder.documents"), ("prompt_builder", "llm")])
)⬆️ Upgrade Notes
-
Serialized
OutputAdapterandConditionalRoutercomponents containing Jinjacustom_filtersmust now be loaded withPipeline.load(..., unsafe=True)(or the equivalentPipeline.loads/Pipeline.from_dictoption). -
The
tool_result_offloadedmeta key that the hook sets on an offloaded message now always holds a list of store references rather than a single reference string, since a result can span several entries. Code reading that key (for example to re-read an offloaded result) should index into the list.ToolResultStore.writeandreadare typedstr | bytes. Nothing changes at runtime for a text-only store, but one that subclasses the protocol and annotatescontent: strmay need a type-checker fix. -
The
+operator for combining Toolsets has been removed. Pass Toolsets as a list wherever tools are accepted instead:Agent(..., tools=[toolset_a, toolset_b]). Pipelines serialized with a+-combined Toolset cannot be loaded anymore (their YAML references the removed internal_ToolsetWrapperclass): recreate them with the list form and serialize them again. -
Toolset.add()now only accepts a single Tool, not another Toolset. To combine Toolsets, pass them as a list:Agent(..., tools=[toolset_a, toolset_b]).
🚀 New Features
-
Add
Pipeline.add_components()to add a mapping of component names to component instances in one call. Re-adding the same component instance under the same name withPipeline.add_component()orPipeline.add_components()is now a no-op.pipeline.add_components( { "retriever": retriever, "prompt_builder": prompt_builder, "llm": llm, } )
-
Added the experimental
SummarizationCompactor(use withCompactionHook), which progressively summarizes a conversation until it fits a target token budget, preserving useful context from long-running Agents instead of dropping older messages.It uses four summarization tiers in order until the target token budget is reached:
historical_turns: Starting with the oldest, summarize as few complete historical turns as needed.historical_summaries: When no complete historical turns remain, combine as few of the oldest historical summaries as needed.current_task_steps: Summarize the fewest oldest steps needed to reach the target while preserving themin_keep_stepsnewest steps.current_task_summaries: When no more steps can be summarized, combine as few of the current task's oldest summaries as needed to reach the target.
from typing import Annotated from haystack.components.agents import Agent from haystack.components.generators.chat import OpenAIResponsesChatGenerator from haystack.hooks.compaction import CompactionHook, SummarizationCompactor from haystack.tools import tool @tool def web_search(query: Annotated[str, "The search query"]) -> str: """Search the web for current information.""" return f"Search results for: {query}" agent_generator = OpenAIResponsesChatGenerator(model="gpt-5.4") summary_generator = OpenAIResponsesChatGenerator(model="gpt-5.4-nano") compaction_hook = CompactionHook( compactor=SummarizationCompactor( chat_generator=summary_generator, min_keep_steps=2, approximate_summary_tokens=1_024, ), context_window=400_000, compact_at=0.7, compact_to=0.4, ) agent = Agent( chat_generator=agent_generator, tools=[web_search], hooks={"before_llm": [compaction_hook]}, )
-
Added
stop_run, a control flag that lets an Agent hook end a run cleanly instead of raising an error. The hook sets it to a reason of your choice (state.set("stop_run", "my_reason")), and the Agent stops without another model call and reports that reason inexit_reason. -
Added
TokenBudgetHook(experimental), a ready-made hook that caps how many tokens an Agent run may spend. The run ends withexit_reasonset to"token_budget_exceeded", keeping the messages collected so far.from haystack.hooks.budget import TokenBudgetHook agent = Agent( chat_generator=OpenAIChatGenerator(model="gpt-5.4-nano"), tools=[search], hooks={"before_llm": [TokenBudgetHook(max_total_tokens=100_000)]}, )
-
Add
Pipeline.connect_many()to connect multiple(sender, receiver)pairs in one call.Pipeline.add_component()andPipeline.add_components()now return the pipeline, allowing pipeline-building methods to be chained.pipeline = ( Pipeline() .add_components( { "retriever": retriever, "prompt_builder": prompt_builder, "llm": llm, } ) .connect_many( [ ("retriever", "prompt_builder.documents"), ("prompt_builder", "llm"), ] ) )
-
Added the
HAYSTACK_UNSAFE_DESERIALIZATIONenvironment variable as a process-wide equivalent of loading withunsafe=True. When set to a truthy value (1ortrue), everyPipeline.load/Pipeline.loads/Pipeline.from_dictcall skips all deserialization safety checks — the module allowlist, the builtin/import-primitive and control-plane denylists, the object-internals traversal guard, and the refusal to honor a component's ownunsafe: trueflag. This is intended for deployments that only ever load fully trusted pipelines and cannot passunsafe=Trueat every call site. A warning is logged the first time it takes effect. Only enable it when every pipeline the process loads is trusted: a single untrusted pipeline then leads to arbitrary code execution.The switch is not limited to pipeline loading: it disables the same checks for every deserialization path in the process, including ones that take no
unsafeargument of their own —Tool.from_dict,State.from_dict(agent snapshot resume), and theConditionalRouterandOutputAdapterJinja sandbox flags. A deployment that loads trusted pipelines at startup but accepts serialized tools or agent state at request time is therefore exposed on the request path as well, not only at load time.The variable is read once, on the first deserialization in the process, and the result is then frozen for the process lifetime: writes to it afterwards are ignored, in either direction. Set it before the first pipeline is loaded. Freezing keeps the safety mode of a process from changing under a caller's feet and, because the first read happens before any deserialized data can run, stop...
v3.2.0-rc1
v3.2.0-rc1
v3.1.1
Release Notes
v3.1.1
Bug Fixes
- The OpenAI Chat Completions and Responses converters no longer raise on an assistant message with no content parts, which a Chat Generator returns when it discards a malformed tool call. It is sent with empty content, which the APIs accept, so the next LLM call goes through.
ChatMessage.from_openai_dict_formataccepts the same empty content, so such a message round-trips. - The Hugging Face message converter no longer raises on an assistant message with no content parts, and sends it with empty content instead.
