Upgrade to Spring Boot 4.1 and Spring AI 2.0#50
Merged
Conversation
The early "preparing a tool call" signal (944e1ac) never fires. In Spring AI 1.1.x MessageAggregator strips toolCalls from the streamed AssistantMessage (spring-ai#3366 / #5167), so hasToolCallDelta() — which reads getOutput().getToolCalls() — is always false at the subscriber, and with internal tool execution the finishReason=tool_calls boundary may not reach the subscriber at all. Add opt-in diagnostics (kb.diag.stream-tool-calls, default true on this branch) that log the exact per-chunk stream shape at three vantage points so the real behavior can be confirmed empirically: - subscriber: what reaches ChatRunService.onNext (where the detector runs) - advisor: innermost StreamAdvisor (can a ChatConfig advisor catch it?) - TOOL STARTED: real tool start, to measure the silent gap before a call Each chunk logs textLen/empty/finishReason/hasToolCalls/outToolCalls. INFO = interesting chunks (empty text / finishReason / tool calls), DEBUG = every chunk (logger io.github.trialiya.kb.diag). No production behavior change when disabled. Doc with how-to-read and the solution options (1.1.x manual loop vs Spring AI 2.0 ToolCallingAdvisor) in docs/проект/диагностика-tool-preparing-стриминг.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013neSEixYXJoMdozigeAex7
First live run proved the subscriber-layer conclusions: on every chunk outToolCalls=[] and hasToolCalls=false (incl. empty chunks), and the only finishReason ever seen is STOP — TOOL_CALLS never reaches the subscriber. So both hasToolCallDelta() and the "TOOL_CALLS".equals(finishReason) fallback are dead in streaming (spring-ai#3366 + internal tool loop swallows the boundary). Two probe fixes: - "interesting" gate now ignores empty-string finishReason (intermediate stream chunks carry "" not null), so INFO is the genuinely interesting subset (empty text / real finishReason / tool calls) and text flow drops to DEBUG — much less noise. - The advisor produced zero lines, so make it unmistakable: log registration at startup, add SUBSCRIBED / finally markers + a chunk counter, and log advisor chunks unconditionally at INFO. This settles "registered but bypassed" vs "not registered" on the next run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013neSEixYXJoMdozigeAex7
…visor Root cause: In Spring AI 1.x, tool calls were processed entirely inside ChatModel internals. StreamAdvisors (even at LOWEST_PRECEDENCE) never saw tool-call chunks or finish_reason=TOOL_CALLS, making TOOL_PREPARING impossible to signal reliably. Spring AI 2.0.0 moves the tool-call loop into ToolCallingAdvisor (outermost advisor). A new ToolPreparingAdvisor at LOWEST_PRECEDENCE (innermost) now sits inside the loop and sees raw model streaming chunks on each iteration — it detects tool-call intent and publishes the TOOL_PREPARING event before execution. Changes: - Bump springAiVersion 1.1.6 → 2.0.0 in build.gradle; regenerate lockfile - Exclude spring-boot-reactor/spring-boot-jackson (Spring Boot 4.x-only modules pulled transitively by spring-ai-starter-model-openai) that break Boot 3.x context loading with ClassNotFoundException on EnvironmentPostProcessor - Add ToolPreparingAdvisor (new): innermost StreamAdvisor that publishes TOOL_PREPARING once per tool-call round before the tool executes - Rewire ChatConfig: ToolCallingAdvisor → MessageChatMemoryAdvisor → ToolPreparingAdvisor (innermost); remove StreamDiagnosticsAdvisor - Fix ChatClientRequestSpec.options() call sites (ChatRunService, ChatController, ChatModelClientIT): 2.0.0 API now accepts Builder, not built ChatOptions - Remove SearchAgentService.internalToolExecutionEnabled(false) — method removed in 2.0.0 (chatModel.call() no longer auto-executes tools) - Delete StreamDiagnostics and StreamDiagnosticsAdvisor (diagnostic code) - Remove kb.diag config from application.yaml Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013neSEixYXJoMdozigeAex7
Gradle extracts spring.factories from Spring Boot 4.x transitive deps into the project root during dependency resolution. This is a build artifact (not project source) and should not be tracked. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013neSEixYXJoMdozigeAex7
The previous comment had the chain backwards — ToolCallingAdvisor was
listed as outermost, but MessageChatMemoryAdvisor (order MIN+200) is
actually more outermost than ToolCallingAdvisor (order MIN+300).
Actual chain, outermost → innermost:
1. MessageChatMemoryAdvisor (MIN+200) — OUTSIDE the tool loop:
loads history once, saves user+assistant only. No tool messages
are written — our JDBC repository does not support them, and this
matches Spring AI 1.x behaviour.
2. ToolCallingAdvisor (MIN+300) — drives the tool-call loop.
Memory is outside the loop so internal conversation accumulation
is correct and .disableInternalConversationHistory() is not needed.
3. ToolPreparingAdvisor (MAX) — INSIDE the loop: emits TOOL_PREPARING
before each tool execution round.
No functional change — Spring AI sorts advisors by getOrder() regardless
of insertion order, so behaviour was already correct. List order and
comment now match the actual execution order.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013neSEixYXJoMdozigeAex7
Spring AI 2.0.0 is built against Spring Boot 4 / Spring Framework 7; its
autoconfiguration classes reference Boot 4-only APIs (e.g. the chat-memory-jdbc
autoconfig needs org.springframework.boot.jdbc.init.DatabaseInitializationProperties),
so the app could not boot on Boot 3.5.15. Bump to Boot 4.1.0 and fix the fallout.
build.gradle:
- Spring Boot plugin 3.5.15 -> 4.1.0; drop the spring-boot-reactor/-jackson
excludes (those are legitimate Boot 4 modules now).
- Boot 4 split many autoconfigurations out of spring-boot-autoconfigure into
per-technology modules that aren't pulled transitively:
* add spring-boot-flyway — FlywayAutoConfiguration moved here; flyway-core no
longer brings it, so without it migrations never run (app startup AND
@DataJdbcTest slices).
- Boot 4 makes Jackson 3 (tools.jackson) the auto-configured mapper and demotes
Jackson 2 to runtime-only. Our internal serialization still targets Jackson 2,
so add jackson-datatype-jsr310 back onto the compile classpath. Web layer uses
Jackson 3; both coexist (supported migration state).
- Boot 4 no longer manages Testcontainers versions: import testcontainers-bom.
- Boot 4 moved test slices into spring-boot-<module>-test artifacts: add
spring-boot-data-jdbc-test (@DataJdbcTest) and spring-boot-jdbc-test
(@AutoConfigureTestDatabase).
- Regenerate gradle.lockfile (Framework 7, Security 7, Data 4, Jackson 2+3,
JUnit 6, Tomcat 11, H2 2.4.240, Flyway 12).
tests:
- Update relocated slice imports: ...test.autoconfigure.data.jdbc.DataJdbcTest ->
...data.jdbc.test.autoconfigure.DataJdbcTest, and the jdbc AutoConfigureTestDatabase.
- @DataJdbcTest no longer imports Flyway (and @OverrideAutoConfiguration disables
the rest), so @ImportAutoConfiguration(FlywayAutoConfiguration.class) on the
Postgres IT base class and on DocumentServiceUnitTest.
- ChatModelClientIT: Spring AI 2.0.0 reads ChatModel.getOptions() in
DefaultChatClientUtils — stub it (was NPEing on null).
- DocumentServiceUnitTest: Spring 7 canonicalizes 422 as UNPROCESSABLE_CONTENT
(HttpStatus.valueOf(422)); update expectations.
- SearchAgentServiceIT: 2.0.0 leaves an unset tool-callback list null (1.x: empty)
— assert isNullOrEmpty for the tool-less summarization call.
Verified on JDK 21: spotlessCheck, all *Test unit tests, and SearchAgentServiceIT
pass. Docker-backed *IT tests and full app boot to be verified locally on JDK 25.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013neSEixYXJoMdozigeAex7
…able TOOL_PREPARING arrives immediately before TOOL_CALL because OpenAiChatModel.internalStream buffers all tool-call deltas via bufferUntil/ChunkMerger before emitting — every public extension point (StreamAdvisor, observation) sits above that buffer. chatEventReducer: handler reduced to no-op with explanation. docs/features/tool-preparing.md: documents the buffer mechanism, all investigated hooks, and the two viable alternatives (frontend silence-timer / AsyncStreamResponse decorator). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013neSEixYXJoMdozigeAex7
Open
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Upgrades the backend from Spring Boot 3.5.15 + Spring AI 1.1.6 to Spring Boot 4.1.0 + Spring AI 2.0.0, along with related dependency updates (Tomcat 11, Micrometer 1.17, Jackson 3, etc.). Includes necessary configuration adjustments and test import fixes for the new framework versions.
Key Changes
Framework upgrades:
New dependencies:
com.openai:openai-java-core:4.39.1(OpenAI SDK)com.squareup.okhttp3:okhttp:4.12.0+ okio (HTTP client)org.jetbrains.kotlin:kotlin-reflect:2.3.21commons-logging:commons-logging:1.3.6Spring Boot 4 migration fixes:
spring-boot-flywaymodule (Spring Boot 4 extracted FlywayAutoConfiguration)@DataJdbcTestand@AutoConfigureTestDatabasemoved toorg.springframework.boot.data.jdbc.test.autoconfigureandorg.springframework.boot.jdbc.test.autoconfigure@ImportAutoConfiguration(FlywayAutoConfiguration.class)to test base classes to ensure migrations run in test slicesTool preparing advisor:
ToolPreparingAdvisor(newStreamAdvisoratLOWEST_PRECEDENCE) to detect tool calls early and publishTOOL_PREPARINGeventsChatConfigas innermost advisor in the chainRUN_ID_PARAMthrough advisor context for event correlationpreparingflag logic fromChatRunService(now handled by advisor)Documentation:
docs/features/tool-preparing.md— explains theTOOL_PREPARINGevent, why it's currently disabled, and implementation options (silence detection on frontend vs. OpenAI SDK decorator)docs/проект/диагностика-tool-preparing-стриминг.md— diagnostic guide for understanding tool call streaming behavior in Spring AI 2.0Frontend:
TOOL_PREPARINGevent handler inchatEventReducer.jswith detailed comment explaining why the signal arrives too late (OpenAI SDK buffers tool-call deltas internally)Build & config:
.gitignoreto exclude Spring Boot 4 build artifacts (META-INF/)application.yaml(INFO)Implementation Notes
The
ToolPreparingAdvisoris registered as the innermost advisor (highest precedence toward the model) to observe tool calls as early as possible within the Spring AI advisor chain. However, due to OpenAI SDK's internal buffering of tool-call deltas viabufferUntil, the signal still arrives nearly simultaneously withTOOL_CALL. The documentation outlines why earlier detection requires either:2
https://claude.ai/code/session_013neSEixYXJoMdozigeAex7