Both Spring AI and Solon AI can build tool-calling, multi-step agents in Java. But once your agent grows beyond a single chat call — you want step-by-step streaming, per-tool tracing, run metrics, or human-in-the-loop suspension — the two frameworks diverge sharply in how you assemble the agent and how you observe its execution.
This article compares the two side by side: agent construction first, then the event/observability models, with runnable examples for each. Everything below is checked against the official docs (Spring AI reference) and the Solon AI v4.0.5 source code.
Part 1: Building Agents
Spring AI: The Agent Is a Pattern, Not a Type
Spring AI has no Agent class. An "agent" is a composition: a ChatClient with advisors, plus tools, plus (optionally) memory. The tool-calling loop is run by the framework through the always-auto-registered ToolCallingAdvisor, which delegates execution to ToolCallingManager and loops until the model stops requesting tools.
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem("You are a helpful weather assistant.")
.defaultTools(new WeatherTools()) // @Tool-annotated methods
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
String answer = client.prompt()
.user("What's the weather in Hangzhou? Should I bring an umbrella?")
.call()
.content();
Spring AI also documents three tool-execution modes:
- Framework-controlled — the default above; you never see the loop.
-
Advisor-controlled — you configure
ToolCallingAdvisorexplicitly to customize the loop's stop condition. -
User-controlled — you disable the auto-registered advisor (
AdvisorParams.toolCallingAdvisorAutoRegister(false)) and drive the loop yourself, aggregating streamed chunks withChatClientMessageAggregator.
For multi-agent orchestration, Spring AI's documented pattern is composition: one ChatClient can be exposed as a ToolCallback for another, so a "planner" agent literally calls a "worker" agent as a tool.
// A worker agent exposed as a tool (documented pattern)
ToolCallback researcherTool = FunctionToolCallback
.builder("researcher", query -> researcherClient.prompt()
.user(query)
.call()
.content())
.description("Ask the researcher sub-agent")
.inputType(String.class)
.build();
The strength here is familiarity: if you know ChatClient + advisors, you already know how to build agents. The weakness is that there is no first-class object representing "an agent run" — no run ID, no run-scoped trace, no built-in notion of an agent's lifecycle.
Solon AI: Agents Are First-Class Citizens
Solon AI ships an actual agent layer in solon-ai-agent. The root interface Agent declares name(), role(), profile(), and the request builders prompt(...). Out of the box you get three levels:
-
ReActAgent— a single agent running a think → act → observe loop with tools. -
TeamAgent— a coordinator that plans and delegates to multiple member agents. - Ai Flow — YAML-declared workflows where agents are nodes.
Building a ReAct agent is a builder chain over the same portable ChatModel:
public class WeatherTools extends AbsToolProvider {
@ToolMapping(description = "Get current weather for a city")
public String getWeather(@Param(description = "City name") String city) {
return weatherService.lookup(city);
}
}
ChatModel chatModel = ChatModel.of(apiUrl)
.apiKey(apiKey).model("gpt-4o")
.build();
ReActAgent agent = ReActAgent.of(chatModel)
.name("weather_helper")
.role("Answers weather questions and gives clothing advice")
.modelOptions(o -> o.temperature(0.1F)) // low temp for consistent reasoning
.defaultToolAdd(new WeatherTools())
.maxTurns(10) // hard cap on the reasoning loop
.outputSchema(WeatherAdvice.class) // structured final answer
.build();
AgentSession session = InMemoryAgentSession.of("session-001");
String answer = agent.prompt("What's the weather in Hangzhou?")
.session(session)
.call()
.getContent();
Three things are worth noticing versus Spring AI:
-
The loop has a budget —
maxTurns,retryConfig(maxRetries, retryDelayMs), andautoRethinkare builder-level controls on the reasoning loop itself, not something you wire by hand. -
The session is the agent's memory —
AgentSessioncarries the working memory and execution snapshot; it can be serialized to JSON (FlowContext.toJson()/fromJson()) and restored, which also powers human-in-the-loop:session.pending(true, "waiting for approval")suspends mid-run, andagent.prompt()(no argument) resumes it later. -
Async and streaming are symmetric — every agent request supports
.call(),.callAsync()(CompletableFuture), and.stream().
A TeamAgent reuses the same builder shape, and notably can work without its own model — TeamAgent.of(null) builds a deterministic coordinator driven purely by code:
TeamAgent team = TeamAgent.of(chatModel)
.name("support_team")
.defaultInterceptorAdd(new AuditInterceptor())
.build();
Part 2: Events and Observability
This is where the architectural difference is deepest.
Spring AI: Observability via Micrometer, Not an Event Stream
Spring AI's answer to "what happened inside my agent run" is the Micrometer Observation API. ChatClient calls and tool executions automatically record observations (spring.ai.chat.client, spring.ai.tool) with GenAI-convention key-values:
-
gen_ai.operation.name(execute_toolfor tools) -
spring.ai.tool.definition.name,spring.ai.tool.call.id - tool call arguments and results are not exported by default (sensitive data); opt in with
spring.ai.tools.observations.include-content=true
These feed tracing backends (Zipkin, Tempo, ...) via standard Spring Boot actuator configuration. It is production-grade telemetry — but it is metrics and spans, not events you can program against. There is no RunStarted callback, no per-tool-call hook you can implement to mutate behavior, and no run object carrying token usage you can read in-process after a run.
For streaming, you get Flux<ChatResponse> content chunks; and if you drive the tool loop yourself, ChatClientMessageAggregator lets you tap each iteration's chunk flux — but the lifecycle of "a tool was selected → executed → returned" surfaces only through observation spans.
Solon AI: A Typed Event Stream Plus Lifecycle Interceptors
Solon AI treats the agent run as a stream of typed events. AgentRequest.stream() returns Flux<AgentEvent>, and every event carries getRunId(), getAgentName(), the AgentSession, and a metadata map:
agent.prompt("What's the weather in Hangzhou?")
.session(session)
.stream()
.doOnNext(event -> {
if (event instanceof RunStartEvent e) {
log.info("run {} started on agent {}", e.getRunId(), e.getAgentName());
} else if (event.hasContent()) {
// incremental token stream for the UI
uiSink.emit(e.getContent());
} else if (event instanceof RunEndEvent e) {
Metrics m = e.getMetrics();
log.info("run finished: abnormal={}, tokens={}, duration={}ms",
e.isAbnormal(), m.getTotalTokens(), m.getTotalDuration());
}
})
.subscribe();
The event vocabulary is small and structural:
| Event | Meaning |
|---|---|
RunStartEvent |
an agent run begins (carries ReActTrace) |
content events (hasContent()) |
streaming answer chunks |
RunEndEvent |
run finished — carries the final ReActResponse, the trace, and Metrics (prompt/completion/total tokens, cache tokens, total duration, isAbnormal()) |
TeamStartEvent / NodeStartEvent / NodeChunk / NodeEndEvent / TeamEndEvent
|
team-level granularity: which member agent started, streamed, finished |
Because events are typed objects on a Reactor flux, wiring them to SSE, WebSockets, or an audit log is trivial — no observation registry required.
Complementing the event stream is the interceptor chain. ReActInterceptor gives you synchronous hooks into every phase of the loop, registered per agent:
public class AuditInterceptor implements ReActInterceptor {
@Override
public void onReasonStart(ReActTrace trace, StringBuilder systemPromptBuf) {
trace.getRunId(); // correlate with the event stream
}
@Override
public void onToolCallStart(ReActTrace trace, ToolExchanger toolExchanger) {
log.info("calling tool: {}", toolExchanger.getToolName());
}
@Override
public void onToolCallEnd(ReActTrace trace, ToolExchanger toolExchanger,
ChatMessage message, Throwable error, long durationMs) {
log.info("tool {} done in {}ms, error={}",
toolExchanger.getToolName(), durationMs, error);
}
@Override
public void onObservation(ReActTrace trace, ToolExchanger toolExchanger,
@Nullable ChatMessage observation,
@Nullable Throwable error, long durationMs) {
// the observation that will be fed back to the model
}
}
ReActAgent agent = ReActAgent.of(chatModel)
.defaultToolAdd(new WeatherTools())
.defaultInterceptorAdd(new AuditInterceptor())
.build();
The full hook surface: onAgentStart, onReasonStart / onReasonEnd, onPlan, onActionStart / onActionEnd, onToolCallStart / onToolCallEnd, onAgentEnd — plus onThought / onAction / onObservation mirroring the classic ReAct vocabulary. Since ReActInterceptor extends both AgentInterceptor and ChatInterceptor, one object can observe agent-level and raw-model-level traffic simultaneously.
And unlike observation spans, these hooks can do things: mutate the system prompt buffer at onReasonStart, redact tool results, enforce budgets, or suspend the run.
The Philosophical Difference in One Table
| Aspect | Spring AI | Solon AI |
|---|---|---|
| Agent abstraction | none — ChatClient + advisors pattern |
Agent / ReActAgent / TeamAgent types |
| Tool loop owner |
ToolCallingAdvisor (framework, advisor, or user-controlled) |
ReActAgent loop with maxTurns / retryConfig
|
| Multi-agent | compose clients-as-tools | built-in TeamAgent coordinator |
| Execution events | not exposed as API | typed Flux<AgentEvent> stream |
| Per-tool hooks | via Micrometer spans |
ReActInterceptor lifecycle callbacks |
| Token/cost per run | from observation metrics export |
Metrics on RunEndEvent, in-process |
| Mid-run suspension | DIY |
session.pending() + resume |
| Telemetry backend | Micrometer / OTel, first-class | read events/trace in code; bridge to whatever you like |
Which One Fits?
These models are not enemies — they answer different pressures:
- If your agents live inside a Spring estate and your observability story is already Micrometer/tracing-based, Spring AI's pattern-based approach slots in with zero new concepts, and spans flow to your existing dashboards.
- If you are building agent-centric products — dashboards that render reasoning steps, billing driven by per-run token metrics, HITL approval flows, SSE streams of typed events — Solon AI's first-class run/event/interceptor model gives you that without wrapping observation infrastructure in application code.
A pragmatic hybrid also works well and is worth stating plainly: Spring Boot as the shell (web, security, actuator) with Solon AI's agent kernel inside it — Solon AI is framework-neutral, so ReActAgent runs happily as a plain bean.
Further Reading
- Spring AI: Tool Calling and Observability chapters — docs.spring.io/spring-ai/reference
- Solon AI docs — solon.noear.org
Top comments (0)