Spring AI 2.1 Message Parts: Reasoning, Tool Calls and Media in Provider Order
Spring AI 2.1.0-M1 models an assistant turn as an ordered list of parts. Here is what the model looks like, how streaming marks partial parts, and where reasoning is silently lost today.
Until now a Spring AI AssistantMessage was a bag: some text, a list of tool calls, a few media. If a model thought, called a tool, and then spoke, the order in which that happened was gone by the time you held the object. Spring AI 2.1.0-M1 adds a second view of the same message: an ordered list of parts. This post builds one, streams one, and then shows the part that surprised me, which is how easily the reasoning in it disappears when you save the conversation.
Versions and limits. Spring AI 2.1.0-M1, a milestone: the API can still change before 2.1 GA (the current GA line is 2.0.1). JDK 25 (Temurin 25.0.4.1). Everything below was compiled and run, but with hand-built messages and no live model, because I had no provider key in my sandbox. I therefore did not check which providers fill in parts today. The brief I worked from says only the OpenAI Responses model does so natively; I could not confirm that against a source or a live run, so treat it as unverified. The code is two small files you can download: PartsDemo.java and JdbcMemoryDemo.java.
The failure first: reasoning vanishes in JDBC chat memory
I built an assistant turn that contains a reasoning part, saved it to a chat memory repository, and loaded it back. With the in-memory repository the reasoning came back intact. With the JDBC repository (H2, using the schema file the artifact ships) it did not:
before save: reasoning parts = 1
after reload: reasoning parts = 0
after reload: text = Checking the weather for Mumbai.
stored row : [{TYPE=USER, CONTENT=Weather in Mumbai?}, {TYPE=ASSISTANT, CONTENT=Checking the weather for Mumbai.}]
The table stores two columns that matter, the message type and the text. A reasoning part, with its summary and provider signature, has nowhere to go, so it is not written and no error is raised. This output comes from JdbcMemoryDemo.java. I only tested the in-memory and JDBC repositories; I did not test the Redis or other repositories, so do not read this as a statement about them.
Why it matters: some providers require the reasoning item they gave you to be sent back on the next turn, signed, so they can continue the same line of thought. If your memory drops it, that continuity is lost. If you use JDBC memory today, my advice is to keep behaving as you did before: the conversation text survives, the hidden reasoning does not. Details of the JDBC and Redis repositories are in Chat Memory in Spring AI 2.0.
What a part is
Think of a chat reply from a model as a recording of its turn, not just its final sentence. A reasoning model may emit a thinking item, then ask for a tool call, then write the answer. Those are three things in a fixed order. A MessagePart is one such thing. The package org.springframework.ai.chat.messages.part in 2.1.0-M1 contains:
TextPart, plain text.
ReasoningPart, with text, a summary, an optional signed payload and free-form attributes.
ToolCallPart and ToolResultPart.
MediaPart.
UnknownPart(provider, kind, rawJson), so a part type Spring does not model yet is kept rather than discarded.
OpaquePayload(provider, kind, data), the carrier for a provider signature.
The diagram is the whole idea. One list, in the order the provider produced it. The older accessors still work; they just pick items out of that list.
Build a turn and read it back
The first section of PartsDemo.java builds a turn with the builder’s part(...) method:
OpaquePayload signature = new OpaquePayload("openai", "reasoning.encrypted_content", "gAAAA-not-a-real-signature");
AssistantMessage turn = AssistantMessage.builder()
.part(new ReasoningPart("Need the weather first.", "Plans a lookup", signature, Map.of()))
.part(ToolCallPart.of(new ToolCall("call_1", "function", "getWeather", "{\"city\":\"Mumbai\"}")))
.part(TextPart.of("Checking the weather for Mumbai."))
.build();
Running it prints the order, and shows the old accessors still work:
ReasoningPart
ToolCallPart
TextPart
getText() = Checking the weather for Mumbai.
getToolCalls() = 1
getReasoning() = 1
The signature string is a placeholder I wrote; it is not a real provider value.
Streaming: partial parts carry an index
When a reply streams, a text part arrives in pieces. StreamingParts marks them with two attributes so you can tell a fragment from a finished part and know which part it belongs to:
Two fragments (Hel, lo) are partial; the completed one is not; strip removes the bookkeeping attributes so you can store a clean part. These are constructed by hand in the demo with StreamingParts.partial and complete. I did not observe a real provider stream emit them.
Replaying a provider signature
A signed reasoning part is only meaningful to the provider that issued it. replayableTo(String) answers whether a part may be sent to a given provider:
signed -> openai : true
signed -> anthropic : false
unsigned -> openai : true
This is the practical use. If you switch providers mid-conversation, as in switching between Claude, OpenAI and Gemini, a signed part from one should not be replayed to another, and an unsigned plain-text one can be.
The in-memory round trip, with the payload
reasoning parts after reload = 1
payload kept = OpaquePayload[provider=openai, kind=reasoning.encrypted_content, data=gAAAA-not-a-real-signature]
InMemoryChatMemoryRepository stores the message object itself, so everything in it, including the payload, survives. That is why the in-memory result and the JDBC result differ. Both come from the same turn.
Setup to compile the demos yourself
Import the Spring AI BOM 2.1.0-M1 (milestone repository needed), then add spring-ai-model for PartsDemo. For JdbcMemoryDemo also add spring-ai-model-chat-memory-repository-jdbc, spring-jdbc and the H2 driver. Both are plain main methods; compile with javac and run with java on that classpath.
Should you even do this? Not yet for production. It is a milestone, I could not confirm which providers populate parts, and the persistence gap above means reasoning is lost on JDBC memory. If you use only text and tool calls, the existing accessors are enough and you lose nothing. Parts become worth adopting when you run a reasoning model that needs its signed reasoning replayed, you keep conversations in memory, and you are prepared to move to 2.1 GA. Moving from 1.x first? Read the 1.x to 2.0 migration guide.
No Comments yet!