DEV Community

Solon Framework
Solon Framework

Posted on

Spring AI vs Solon AI - Building Agents and Handling Agent Events

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();
Enter fullscreen mode Exit fullscreen mode

Spring AI also documents three tool-execution modes:

  1. Framework-controlled — the default above; you never see the loop.
  2. Advisor-controlled — you configure ToolCallingAdvisor explicitly to customize the loop's stop condition.
  3. User-controlled — you disable the auto-registered advisor (AdvisorParams.toolCallingAdvisorAutoRegister(false)) and drive the loop yourself, aggregating streamed chunks with ChatClientMessageAggregator.

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Three things are worth noticing versus Spring AI:

  1. The loop has a budgetmaxTurns, retryConfig(maxRetries, retryDelayMs), and autoRethink are builder-level controls on the reasoning loop itself, not something you wire by hand.
  2. The session is the agent's memoryAgentSession carries 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, and agent.prompt() (no argument) resumes it later.
  3. 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 modelTeamAgent.of(null) builds a deterministic coordinator driven purely by code:

TeamAgent team = TeamAgent.of(chatModel)
        .name("support_team")
        .defaultInterceptorAdd(new AuditInterceptor())
        .build();
Enter fullscreen mode Exit fullscreen mode

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_tool for 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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)