<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Solon Framework</title>
    <description>The latest articles on DEV Community by Solon Framework (@solonjava).</description>
    <link>https://dev.to/solonjava</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4003833%2F83933c1e-7d66-44d1-9237-16669f6b9a80.png</url>
      <title>DEV Community: Solon Framework</title>
      <link>https://dev.to/solonjava</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/solonjava"/>
    <language>en</language>
    <item>
      <title>One Event Stream, Two UI Protocols: Wiring Solon AI to Any Frontend</title>
      <dc:creator>Solon Framework</dc:creator>
      <pubDate>Tue, 06 Oct 2026 04:03:37 +0000</pubDate>
      <link>https://dev.to/solonjava/one-event-stream-two-ui-protocols-wiring-solon-ai-to-any-frontend-318h</link>
      <guid>https://dev.to/solonjava/one-event-stream-two-ui-protocols-wiring-solon-ai-to-any-frontend-318h</guid>
      <description>&lt;p&gt;Your Java agent streams tokens. Your frontend speaks a protocol. Between them sits a translation layer that nobody wants to write — and that everybody writes badly.&lt;/p&gt;

&lt;p&gt;It is the same story every time: text deltas, reasoning deltas, tool-call arguments, tool results, citations, errors, aborts. Six or seven channels, all multiplexed onto one SSE connection, all needing stable IDs so the UI can stitch deltas back into messages. Get the ID keying wrong and two parallel tool calls collide. Forget to close a block on error and the frontend hangs forever waiting for an &lt;code&gt;end&lt;/code&gt; that never comes. Cancel the request and — if you forgot one line — the model keeps generating, and you keep paying.&lt;/p&gt;

&lt;p&gt;Solon AI has a module whose only job is this translation layer: &lt;strong&gt;&lt;code&gt;solon-ai-ui&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/opensolon/solon-ai" rel="noopener noreferrer"&gt;https://github.com/opensolon/solon-ai&lt;/a&gt; (module: &lt;code&gt;solon-ai-ui&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;It ships two adapters:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Artifact&lt;/th&gt;
&lt;th&gt;Protocol it speaks&lt;/th&gt;
&lt;th&gt;Pairs with&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;solon-ai-ui-aisdk&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Vercel AI SDK — UI Message Stream Protocol v1&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@ai-sdk/react&lt;/code&gt;, &lt;code&gt;@ai-sdk/vue&lt;/code&gt; &lt;code&gt;useChat&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;solon-ai-ui-agui&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;AG-UI&lt;/td&gt;
&lt;td&gt;AG-UI compatible component libraries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both convert Solon AI's internal &lt;code&gt;Flux&amp;lt;ChatEvent&amp;gt;&lt;/code&gt; into a frontend-facing event stream. Neither is a rewrite of your agent — they are thin adapters, and the way they stay thin is the most interesting part of the design.&lt;/p&gt;




&lt;h2&gt;
  
  
  The layering rule: adapters may not know what an agent is
&lt;/h2&gt;

&lt;p&gt;The obvious implementation would be: &lt;code&gt;ui&lt;/code&gt; depends on &lt;code&gt;agent&lt;/code&gt;, and maps &lt;code&gt;AgentEvent&lt;/code&gt; to UI events directly. Simple. And wrong — it would drag the entire agent stack into every app that just wants to stream a chat model, and every agent event added upstream would become a breaking change downstream.&lt;/p&gt;

&lt;p&gt;So the adapters depend only on &lt;code&gt;solon-ai-core&lt;/code&gt;. Agent events arrive as &lt;code&gt;Object&lt;/code&gt;, and the adapter figures them out reflectively:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the event exposes &lt;code&gt;getChatEvent()&lt;/code&gt;, the adapter delegates to the core event state machine. The three delta events — the ones carrying actual text, reasoning, and tool arguments — all take this path.&lt;/li&gt;
&lt;li&gt;Otherwise it matches on the simple class name: &lt;code&gt;ToolCallStartEvent&lt;/code&gt;, &lt;code&gt;ToolCallEndEvent&lt;/code&gt;, &lt;code&gt;RunEndEvent&lt;/code&gt; / &lt;code&gt;SimpleEndEvent&lt;/code&gt; / &lt;code&gt;TeamEndEvent&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Anything it still doesn't recognize falls through to a &lt;code&gt;custom&lt;/code&gt; / &lt;code&gt;data-*&lt;/code&gt; bucket instead of being dropped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing is silently discarded. That is the rule the whole module is built around.&lt;/p&gt;




&lt;h2&gt;
  
  
  Adapter 1: Vercel AI SDK
&lt;/h2&gt;

&lt;p&gt;The AI SDK adapter converts &lt;code&gt;chatModel.prompt(prompt).stream()&lt;/code&gt; into a &lt;code&gt;Flux&amp;lt;SseEvent&amp;gt;&lt;/code&gt; that is a drop-in for &lt;code&gt;useChat&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Controller&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AiChatController&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Inject&lt;/span&gt;
    &lt;span class="nc"&gt;ChatModel&lt;/span&gt; &lt;span class="n"&gt;chatModel&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;AiSdkStreamWrapper&lt;/span&gt; &lt;span class="n"&gt;wrapper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AiSdkStreamWrapper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="nd"&gt;@Produces&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MimeType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TEXT_EVENT_STREAM_UTF8_VALUE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@Mapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/ai/chat/stream"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SseEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Context&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// required by the AI SDK protocol&lt;/span&gt;
        &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;headerSet&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"x-vercel-ai-ui-message-stream"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"v1"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;wrapper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toAiSdkStream&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The protocol is a &lt;strong&gt;parts&lt;/strong&gt; model — roughly twenty part types, each a small JSON frame — and the wrapper emits them in a fixed order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;start → (message-metadata) → start-step
  → (reasoning-start → reasoning-delta* → reasoning-end)
  → (tool-input-start → tool-input-delta* → tool-input-available → tool-output-available)
  → (source-url* / source-document*)
  → (text-start → text-delta* → text-end)
  → (file* / data-*)
→ finish-step → … → finish → [DONE]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A single-turn reply is one step. A tool call that re-prompts the model produces multiple steps, and the &lt;code&gt;start-step&lt;/code&gt; / &lt;code&gt;finish-step&lt;/code&gt; pair is what lets &lt;code&gt;useChat&lt;/code&gt; reassemble a multi-step assistant turn correctly.&lt;/p&gt;

&lt;p&gt;There is also a blocking counterpart: &lt;code&gt;toAiSdkStream(ChatResponse)&lt;/code&gt; wraps a &lt;code&gt;call()&lt;/code&gt; result into the same frame sequence, so a non-streaming endpoint can still feed a streaming client.&lt;/p&gt;




&lt;h2&gt;
  
  
  Adapter 2: AG-UI
&lt;/h2&gt;

&lt;p&gt;The AG-UI adapter targets a different event vocabulary — &lt;code&gt;RUN_STARTED&lt;/code&gt;, &lt;code&gt;TEXT_MESSAGE_CONTENT&lt;/code&gt;, &lt;code&gt;TOOL_CALL_ARGS&lt;/code&gt;, &lt;code&gt;REASONING_MESSAGE_CONTENT&lt;/code&gt;, &lt;code&gt;STEP_FINISHED&lt;/code&gt;, and so on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;AgUiStreamWrapper&lt;/span&gt; &lt;span class="n"&gt;wrapper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AgUiStreamWrapper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"thread-1"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"run-1"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wrapper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toAgUiStream&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details are worth calling out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reasoning rename is handled for you.&lt;/strong&gt; AG-UI's modern vocabulary is &lt;code&gt;REASONING_*&lt;/code&gt;; the older &lt;code&gt;THINKING_*&lt;/code&gt; events are marked &lt;code&gt;@Deprecated&lt;/code&gt; in the enum, with each old constant pointing at its replacement. Solon AI's core still calls its events &lt;code&gt;THINKING_*&lt;/code&gt;, so the adapter maps them onto the modern &lt;code&gt;REASONING_START&lt;/code&gt; / &lt;code&gt;REASONING_MESSAGE_CONTENT&lt;/code&gt; / &lt;code&gt;REASONING_END&lt;/code&gt; family — including the message-level boundaries, not just the outer block.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Interruption is a first-class outcome, not an error.&lt;/strong&gt; When the core emits &lt;code&gt;ABORT&lt;/code&gt;, the AG-UI adapter closes any open content block, then emits a &lt;code&gt;RUN_FINISHED&lt;/code&gt; whose outcome type is &lt;code&gt;interrupt&lt;/code&gt;. A user pressing "stop" is a normal ending with a name — not a fake failure.&lt;/p&gt;

&lt;p&gt;Events AG-UI has no standard representation for — server-side tools, media, safety, usage, custom payloads — are preserved as &lt;code&gt;CUSTOM&lt;/code&gt; rather than mapped onto something semantically wrong. For backwards compatibility the payload is written to both the standard &lt;code&gt;name&lt;/code&gt;/&lt;code&gt;value&lt;/code&gt; fields and the legacy &lt;code&gt;rawEvent&lt;/code&gt; field, so older clients keep working while standard clients move forward.&lt;/p&gt;

&lt;p&gt;There is also typed support for state sync: &lt;code&gt;StateDeltaEvent&lt;/code&gt; carries RFC 6902 JSON Patch operations.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;StateDeltaEvent&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StateDeltaEvent&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;JsonPatchOperation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;replace&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/progress"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;JsonPatchOperation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/message"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"working..."&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The five details that decide whether this works in production
&lt;/h2&gt;

&lt;p&gt;Protocol mapping is the easy 20%. These are the rest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Stable IDs across deltas.&lt;/strong&gt; Deltas arrive in fragments, so each block needs an ID minted once and reused for its &lt;code&gt;start&lt;/code&gt; / &lt;code&gt;delta&lt;/code&gt; / &lt;code&gt;end&lt;/code&gt; frames. Both adapters key the ID map on &lt;code&gt;responseId + step + itemId&lt;/code&gt;, falling back to &lt;code&gt;index&lt;/code&gt;. That key is what keeps two concurrent tool calls, or two reasoning channels, from borrowing each other's IDs. The AI SDK adapter also lets you swap the ID source entirely — UUID by default, snowflake or anything else via &lt;code&gt;AiSdkIdGenerator&lt;/code&gt;, with prefixed helpers (&lt;code&gt;msg_&lt;/code&gt;, &lt;code&gt;txt_&lt;/code&gt;, &lt;code&gt;rsn_&lt;/code&gt;, &lt;code&gt;call_&lt;/code&gt;, &lt;code&gt;src_&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Cancellation has to propagate upstream.&lt;/strong&gt; Both wrappers call &lt;code&gt;sink.onDispose(upstream)&lt;/code&gt;. When the browser disconnects, the subscription to the model is released — the request doesn't keep running in the background burning tokens after nobody is listening.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Failures must not be dressed up as success.&lt;/strong&gt; If the stream errors mid-flight, the wrapper first closes any open text/reasoning blocks (otherwise the client waits forever for an &lt;code&gt;end&lt;/code&gt;), then emits the error part with &lt;code&gt;finishReason&lt;/code&gt; set to &lt;code&gt;error&lt;/code&gt; — not the default &lt;code&gt;stop&lt;/code&gt;. The error text is taken from the terminal &lt;code&gt;ERROR&lt;/code&gt; event when one was emitted, because that carries more context than the bare &lt;code&gt;Throwable&lt;/code&gt;. If the error path had to synthesize the &lt;code&gt;end&lt;/code&gt; frames itself, it reuses the same closing logic as the success path rather than inventing new frames.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. No orphan tool output.&lt;/strong&gt; Some providers deliver a tool result without ever sending a &lt;code&gt;tool-input-*&lt;/code&gt; frame. A strict client will drop an output that references an input it never saw. So the adapter idempotently emits the missing &lt;code&gt;tool-input-start&lt;/code&gt; / &lt;code&gt;tool-input-available&lt;/code&gt; pair first, then the output. Same guard applies to agent tool events arriving from a replay or resume path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Content that isn't the answer must not look like the answer.&lt;/strong&gt; In a multi-agent run, a supervisor's internal routing chatter can arrive on the same stream. Both adapters force those events into a &lt;code&gt;custom&lt;/code&gt; / &lt;code&gt;data-*&lt;/code&gt; bucket — never into the assistant text or reasoning channels, so internal deliberation can't leak into what the user sees as the reply. Similarly, agent turns are namespaced by run and reason ID in the AI SDK adapter, so two turns can't accidentally reuse a closed part ID.&lt;/p&gt;

&lt;p&gt;One more, from the AI SDK adapter's javadoc, worth knowing before you file a bug: the core's default event filter blocks &lt;code&gt;RAW&lt;/code&gt; and &lt;code&gt;HEARTBEAT&lt;/code&gt;, so unmodeled raw frames never reach the wrapper by default. If you want them passed through, opt in explicitly when building the stream:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;chatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;eventFilter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatEventFilter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;all&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Which adapter?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If your frontend…&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;already uses &lt;code&gt;useChat&lt;/code&gt; from &lt;code&gt;@ai-sdk/react&lt;/code&gt; or &lt;code&gt;@ai-sdk/vue&lt;/code&gt;, or any AI-Elements component library&lt;/td&gt;
&lt;td&gt;&lt;code&gt;solon-ai-ui-aisdk&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;targets AG-UI / is protocol-first and wants run/step semantics, or you want typed JSON-Patch state sync&lt;/td&gt;
&lt;td&gt;&lt;code&gt;solon-ai-ui-agui&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;just wants plain SSE text and parses it by hand&lt;/td&gt;
&lt;td&gt;neither — &lt;code&gt;streamText()&lt;/code&gt; is enough&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two are not exclusive. &lt;code&gt;ChatEvent&lt;/code&gt; is Solon AI's internal, provider-agnostic model; each adapter is an outbound projection of it. If you ever genuinely need both, you're translating one core stream two ways, not maintaining two agents.&lt;/p&gt;




&lt;h2&gt;
  
  
  Getting started
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.noear&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;solon-ai-ui-aisdk&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Versions are managed by the Solon AI BOM, so no &lt;code&gt;&amp;lt;version&amp;gt;&lt;/code&gt; is needed. The adapter follows the Solon AI 4.1 line.&lt;/p&gt;




&lt;p&gt;The interesting thing about a translation layer is that the best one is invisible. You wire two lines, the UI renders text, reasoning, tool calls and citations in order, and you never think about it again — until the day a tool call hangs the frontend, and you have to go find out whose job it was to close the block.&lt;/p&gt;

&lt;p&gt;This module's answer to that question is: ours.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;All behavior described above was read from the &lt;code&gt;solon-ai-ui&lt;/code&gt; source in the &lt;a href="https://github.com/opensolon/solon-ai" rel="noopener noreferrer"&gt;opensolon/solon-ai&lt;/a&gt; repository.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>ai</category>
      <category>solon</category>
      <category>agents</category>
    </item>
    <item>
      <title>An Agent That Finishes: Inside Solon AI's Loop Engine</title>
      <dc:creator>Solon Framework</dc:creator>
      <pubDate>Tue, 06 Oct 2026 03:59:20 +0000</pubDate>
      <link>https://dev.to/solonjava/an-agent-that-finishes-inside-solon-ais-loop-engine-43be</link>
      <guid>https://dev.to/solonjava/an-agent-that-finishes-inside-solon-ais-loop-engine-43be</guid>
      <description>&lt;p&gt;Most AI coding agents fail in the same way. Not with a wrong answer — with &lt;strong&gt;an incomplete one&lt;/strong&gt;. They start confidently, get 70% of the way there, announce "done!", and hand you a task you now have to finish yourself.&lt;/p&gt;

&lt;p&gt;That failure isn't a prompting problem. It's a control-flow problem: nothing in the system knows how to tell "finished" from "tired".&lt;/p&gt;

&lt;p&gt;Solon AI ships a module whose entire job is that distinction — &lt;code&gt;solon-ai-loop&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/opensolon/solon-ai" rel="noopener noreferrer"&gt;https://github.com/opensolon/solon-ai&lt;/a&gt; (module: &lt;code&gt;solon-ai-loop&lt;/code&gt;)&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Not to be confused with the &lt;code&gt;/loop&lt;/code&gt; command in SolonCode. That's a feature of a product. This is a Java library you embed in your own application.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. What it actually is
&lt;/h2&gt;

&lt;p&gt;Strip away the naming and &lt;code&gt;solon-ai-loop&lt;/code&gt; is four things bolted together:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A state machine&lt;/strong&gt; — where the work is, and which transitions are legal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A strategy&lt;/strong&gt; — what one iteration means (implement a story? run a pipeline phase? run the test gate?).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A validator&lt;/strong&gt; — whether this iteration passed, and if not, why.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Durable state&lt;/strong&gt; — so the loop can survive a restart, a crash, or a human going home.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The point of the module is that none of these is left implicit. A loop ends because &lt;strong&gt;a criterion was met&lt;/strong&gt;, or because &lt;strong&gt;a patience budget ran out&lt;/strong&gt; — never because the model stopped talking.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The state machine
&lt;/h2&gt;

&lt;p&gt;Eight states, with a whitelist of legal transitions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Active&lt;/th&gt;
&lt;th&gt;Terminal&lt;/th&gt;
&lt;th&gt;Pausable&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IDLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PLANNING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EXECUTING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VERIFYING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FIXING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PAUSED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;— (resumable)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COMPLETED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FAILED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IDLE → PLANNING → EXECUTING → VERIFYING → COMPLETED
                       ↑            │
                       └── FIXING ◄─┘      (the fix loop)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The transition table is enforced in code, not in comments:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;From&lt;/th&gt;
&lt;th&gt;Allowed to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IDLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PLANNING&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PLANNING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;EXECUTING&lt;/code&gt;, &lt;code&gt;PAUSED&lt;/code&gt;, &lt;code&gt;FAILED&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EXECUTING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;VERIFYING&lt;/code&gt;, &lt;code&gt;PAUSED&lt;/code&gt;, &lt;code&gt;FAILED&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VERIFYING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;COMPLETED&lt;/code&gt;, &lt;code&gt;FIXING&lt;/code&gt;, &lt;code&gt;PAUSED&lt;/code&gt;, &lt;code&gt;FAILED&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FIXING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;EXECUTING&lt;/code&gt;, &lt;code&gt;PAUSED&lt;/code&gt;, &lt;code&gt;FAILED&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PAUSED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PLANNING&lt;/code&gt;, &lt;code&gt;EXECUTING&lt;/code&gt;, &lt;code&gt;VERIFYING&lt;/code&gt;, &lt;code&gt;FIXING&lt;/code&gt;, &lt;code&gt;FAILED&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;COMPLETED&lt;/code&gt; / &lt;code&gt;FAILED&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;— (terminal)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things worth noticing. First, &lt;code&gt;FIXING&lt;/code&gt; can only go back to &lt;code&gt;EXECUTING&lt;/code&gt; — a fix is work, not a shortcut to done. Second, &lt;code&gt;VERIFYING&lt;/code&gt; is the &lt;strong&gt;only&lt;/strong&gt; state that can reach &lt;code&gt;COMPLETED&lt;/code&gt;. There is no code path where an executor declares victory about its own output.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Three strategies, three shapes of "keep going"
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Ralph — PRD-driven story loop
&lt;/h3&gt;

&lt;p&gt;Reads a PRD, takes the next unfinished user story by priority, implements it, verifies it, records progress, repeats.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;RalphLoopStrategy&lt;/span&gt; &lt;span class="n"&gt;strategy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RalphLoopStrategy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;verificationRequired&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;criticMode&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"architect"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// architect / critic / codex / none&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;maxIterations&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;storyImplementor&lt;/span&gt;&lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* your agent goes here */&lt;/span&gt; &lt;span class="o"&gt;})&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;storyValidator&lt;/span&gt;&lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="cm"&gt;/* Boolean */&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two hooks are plain functional interfaces — &lt;code&gt;StoryImplementor&lt;/code&gt; is a &lt;code&gt;BiFunction&amp;lt;String, LoopContext, Object&amp;gt;&lt;/code&gt;, &lt;code&gt;StoryValidator&lt;/code&gt; a &lt;code&gt;TriFunction&amp;lt;String, Object, LoopContext, Boolean&amp;gt;&lt;/code&gt;. So the loop engine doesn't care &lt;em&gt;how&lt;/em&gt; a story gets implemented. Wire it to a &lt;code&gt;ReActAgent&lt;/code&gt;, a shell command, a human, whatever. The loop only owns the rhythm.&lt;/p&gt;

&lt;h3&gt;
  
  
  Team Pipeline — phase-ordered collaboration
&lt;/h3&gt;

&lt;p&gt;Runs a fixed sequence of phases with guards between them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;TeamPipelineStrategy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;phases&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Arrays&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asList&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Phase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PLAN&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Phase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PRD&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Phase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;EXEC&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Phase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;VERIFY&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Phase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;FIX&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;maxFixAttempts&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Phases: &lt;code&gt;PLAN&lt;/code&gt;, &lt;code&gt;PRD&lt;/code&gt;, &lt;code&gt;EXEC&lt;/code&gt;, &lt;code&gt;VERIFY&lt;/code&gt;, &lt;code&gt;FIX&lt;/code&gt;, plus &lt;code&gt;COMPLETED&lt;/code&gt; / &lt;code&gt;FAILED&lt;/code&gt; / &lt;code&gt;CANCELLED&lt;/code&gt;. The &lt;code&gt;VERIFY&lt;/code&gt; phase won't proceed unless &lt;code&gt;tasksCompleted &amp;gt;= tasksTotal&lt;/code&gt;, and the fix loop gives up after &lt;code&gt;maxFixAttempts&lt;/code&gt; instead of oscillating forever.&lt;/p&gt;

&lt;h3&gt;
  
  
  UltraQA — the quality gate loop
&lt;/h3&gt;

&lt;p&gt;Run build / test / lint / typecheck. If it fails, fix and run again. Repeat until green, or until you name why you stopped.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;UltraQAStrategy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;goalType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UltraQAStrategy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UltraQAGoalType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TESTS&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// TESTS / BUILD / LINT / TYPECHECK / CUSTOM&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;maxTestAttempts&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  4. The part I actually like: named exit reasons
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GOAL_MET  · MAX_CYCLES · SAME_FAILURE · ENV_ERROR · CANCELLED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SAME_FAILURE&lt;/code&gt; is the interesting one. Every failed gate run is normalised — timestamps, line numbers and other noise stripped — then compared. If the same failure shows up &lt;strong&gt;3 times in a row&lt;/strong&gt; (that's &lt;code&gt;SAME_FAILURE_THRESHOLD&lt;/code&gt;, a public constant), the loop stops.&lt;/p&gt;

&lt;p&gt;That's a very old idea from build systems, and it's exactly right for agents: a loop that keeps producing the identical error is not making progress, no matter how busy it looks. Killing it early and loudly is the feature.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. State that outlives the JVM
&lt;/h2&gt;

&lt;p&gt;Long tasks and short processes are a bad match. So the engine can persist to disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.solon-ai-loop/
├── state/
│   ├── ralph/{sessionId}.json         # Ralph state (+ strategy mutex)
│   ├── team/{sessionId}.json          # Team Pipeline state
│   ├── ultraqa/{sessionId}.json       # UltraQA state
│   └── sessions/{sessionId}.json      # session index, for querying
├── prd/{sessionId}.json               # the PRD document
└── progress/{sessionId}.txt           # progress memory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each file is wrapped in two layers — &lt;code&gt;_meta&lt;/code&gt; (&lt;code&gt;written_at&lt;/code&gt;, &lt;code&gt;mode&lt;/code&gt;, &lt;code&gt;sessionId&lt;/code&gt;) and &lt;code&gt;data&lt;/code&gt; (the full state). Writes go through an atomic-write helper, and the base directory is created with &lt;code&gt;0700&lt;/code&gt; permissions. The &lt;code&gt;sessions/&lt;/code&gt; index is what makes "what was running when I killed it?" an answerable question.&lt;/p&gt;

&lt;p&gt;Because the three strategies share a state directory, they also share a mutex: &lt;code&gt;MutualExclusionGuard&lt;/code&gt; refuses to start Ralph while UltraQA holds the lock (&lt;code&gt;canStartRalph&lt;/code&gt; / &lt;code&gt;canStartUltraQA&lt;/code&gt; / &lt;code&gt;canStartTeam&lt;/code&gt;, with stale-lock cleanup). Two loops fighting over one workspace is a nasty failure mode, and it's designed out rather than documented away.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Validation is an interface, not an opinion
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;Validator&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ValidationResult&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ValidationCriteria&lt;/span&gt; &lt;span class="n"&gt;criteria&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;ValidationResult&lt;/span&gt; &lt;span class="nf"&gt;validateQualityGate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;QualityGate&lt;/span&gt; &lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;ValidationResult&lt;/span&gt; &lt;span class="nf"&gt;validateIteration&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="n"&gt;iterationResult&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ValidationContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Results are one of three things — &lt;code&gt;passed(message)&lt;/code&gt;, &lt;code&gt;failed(message, details)&lt;/code&gt;, or &lt;code&gt;needsFix(message, errors)&lt;/code&gt;. That third one is what feeds the &lt;code&gt;FIXING&lt;/code&gt; state.&lt;/p&gt;

&lt;p&gt;Preset gates cover the boring 80%:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;QualityGate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// compilation, dependencies&lt;/span&gt;
&lt;span class="nc"&gt;QualityGate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;    &lt;span class="c1"&gt;// unit-tests, integration-tests&lt;/span&gt;
&lt;span class="nc"&gt;QualityGate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lint&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;    &lt;span class="c1"&gt;// style, complexity, duplication&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the module ships two built-in verifiers, &lt;code&gt;ArchitectVerifier&lt;/code&gt; and &lt;code&gt;CriticVerifier&lt;/code&gt;, the latter with three modes — &lt;code&gt;architect&lt;/code&gt; (architecture-level changes), &lt;code&gt;critic&lt;/code&gt; (general review) and &lt;code&gt;codex&lt;/code&gt; (test coverage, null handling).&lt;/p&gt;

&lt;p&gt;Internally, each verification carries its own little state machine: &lt;code&gt;PENDING → IMPLEMENTED → AWAITING_REVIEW → ARCHITECT_APPROVED → CRITIC_APPROVED&lt;/code&gt;, with &lt;code&gt;FAILED&lt;/code&gt; after the attempt budget (3 by default) and &lt;code&gt;SKIPPED&lt;/code&gt; as the escape hatch. Verification that isn't tracked is just an opinion; this one leaves a trail.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Autopilot: chaining strategies into one pipeline
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;AutopilotExecutor&lt;/code&gt; composes the whole thing into five stages, each bound to a default strategy:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Default strategy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;EXPANSION&lt;/code&gt; — requirement analysis&lt;/td&gt;
&lt;td&gt;Team Pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PLANNING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Team Pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EXECUTION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ralph&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;QA&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;UltraQA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VALIDATION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Team Pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;COMPLETED&lt;/code&gt; / &lt;code&gt;FAILED&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;PipelineConfig&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PipelineConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;expansionEnabled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;planningEnabled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;executionEnabled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;qaEnabled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;validationEnabled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;strategyForStage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PipelineStage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;EXECUTION&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;RalphLoopStrategy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;maxIterations&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;strategyForStage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PipelineStage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;QA&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;        &lt;span class="nc"&gt;UltraQAStrategy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;maxTestAttempts&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="nc"&gt;AutopilotExecutor&lt;/span&gt; &lt;span class="n"&gt;autopilot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AutopilotExecutor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;future&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;autopilot&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;startPipeline&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;AutopilotExecutor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PipelineRequest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sessionId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Build feature X"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;autopilot&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;formatPipelineHUD&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sessionId&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any stage can be replaced via the &lt;code&gt;StageAdapter&lt;/code&gt; SPI, and the pipeline can be driven by hand — &lt;code&gt;skipStage&lt;/code&gt;, &lt;code&gt;advanceStage&lt;/code&gt;, &lt;code&gt;cancelPipeline&lt;/code&gt;. That matters more than it sounds: an autonomous pipeline you can't interrupt is a liability, and one you can't inspect is a black box.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Wiring it into a Solon app
&lt;/h2&gt;

&lt;p&gt;The module has first-class integration with three neighbours — &lt;code&gt;solon-ai-agent&lt;/code&gt; (agents drive iterations), &lt;code&gt;solon-flow&lt;/code&gt; (a flow context drives phases), and &lt;code&gt;solon-ai-harness&lt;/code&gt; (tool management drives the QA loop):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;LoopAutoConfiguration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;IntegratedComponents&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LoopAutoConfiguration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createDefault&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="nc"&gt;LoopEngine&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;loopEngine&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// c.agentIntegration, c.flowIntegration, c.harnessIntegration&lt;/span&gt;

&lt;span class="nc"&gt;SimpleAgent&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SimpleAgent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;chatModel&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chatModel&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="nc"&gt;LoopSession&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;agentIntegration&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;startAgentDrivenRalphLoop&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Implement user management"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  9. Getting started
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.noear&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;solon-ai-loop&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;4.1.0&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Minimal run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// one-liner, in-memory&lt;/span&gt;
&lt;span class="nc"&gt;LoopEngine&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LoopAutoConfiguration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createDefaultEngine&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// or durable: state under the project dir, monitoring on&lt;/span&gt;
&lt;span class="nc"&gt;LoopEngine&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;LoopAutoConfiguration&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;useDiskState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/path/to/project"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enableMonitoring&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="nc"&gt;LoopConfig&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LoopConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskDescription&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Implement user login feature"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RalphLoopStrategy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;verificationRequired&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;maxIterations&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;maxIterations&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="nc"&gt;LoopSession&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onStateChange&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"state: "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;waitForCompletion&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofSeconds&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="nc"&gt;LoopResult&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getResult&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isSuccess&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" / iterations: "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTotalIterations&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  10. When &lt;em&gt;not&lt;/em&gt; to use this
&lt;/h2&gt;

&lt;p&gt;Being fair about scope:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't reach for it for a single tool call.&lt;/strong&gt; If a &lt;code&gt;ChatModel&lt;/code&gt; + one tool answers the question, a loop engine is overhead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It does not implement anything for you.&lt;/strong&gt; You supply the implementor, the validator, or the stage adapter. It provides the rhythm and the memory, not the intelligence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk persistence is local.&lt;/strong&gt; The state manager writes to the project's filesystem — it's not a distributed job queue. Multi-node orchestration is a different problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it removes is the boring, failure-prone part: deciding what "done" means, remembering where you were, and being honest about when to stop. Every agent framework eventually grows this. Solon AI's version is small, explicit, and — in the spirit of the framework — named after exactly one thing it does.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Verified against the &lt;code&gt;solon-ai-loop&lt;/code&gt; module source (64 Java files) and Maven Central metadata on 2026-10-06. Code samples are taken from the module's own APIs; the version shown is the current 4.1.x line.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>ai</category>
      <category>solon</category>
      <category>agents</category>
    </item>
    <item>
      <title>One Router, Four Strategies: How Solon AI Picks the Right ChatModel</title>
      <dc:creator>Solon Framework</dc:creator>
      <pubDate>Sun, 04 Oct 2026 12:09:13 +0000</pubDate>
      <link>https://dev.to/solonjava/one-router-four-strategies-how-solon-ai-picks-the-right-chatmodel-knh</link>
      <guid>https://dev.to/solonjava/one-router-four-strategies-how-solon-ai-picks-the-right-chatmodel-knh</guid>
      <description>&lt;p&gt;In the multi-model era, your application already talks to more than one LLM: a cheap fast model for casual chat, an expensive smart one for reasoning, plus dedicated code models, vision models, and local small models. That raises a question — &lt;strong&gt;who gets each request?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;solon-ai-router&lt;/code&gt; module in Solon AI (v4.1.0+) answers this the "Solon way": &lt;strong&gt;a router so small it almost doesn't exist&lt;/strong&gt;. Only 6 public classes in the whole module, yet it makes model selection clean.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/opensolon/solon-ai" rel="noopener noreferrer"&gt;https://github.com/opensolon/solon-ai&lt;/a&gt; (module: &lt;code&gt;solon-ai-router&lt;/code&gt;)&lt;/p&gt;




&lt;h2&gt;
  
  
  1. It Does Exactly One Thing: Pick a ChatModel for You
&lt;/h2&gt;

&lt;p&gt;The scope of &lt;code&gt;ChatModelRouter&lt;/code&gt; is deliberately narrow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When a chat request is created, select one physical model from a set of &lt;strong&gt;already-built&lt;/strong&gt; &lt;code&gt;ChatModel&lt;/code&gt; instances;&lt;/li&gt;
&lt;li&gt;Return the &lt;code&gt;ChatRequestDesc&lt;/code&gt; produced by that model, untouched.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note the second half — &lt;strong&gt;the Router never hijacks request logic&lt;/strong&gt;. After routing, &lt;code&gt;session()&lt;/code&gt;, &lt;code&gt;role()&lt;/code&gt;, &lt;code&gt;instruction()&lt;/code&gt;, &lt;code&gt;systemPrompt()&lt;/code&gt;, &lt;code&gt;options()&lt;/code&gt;, &lt;code&gt;call()&lt;/code&gt;, and &lt;code&gt;stream()&lt;/code&gt; are all handled by the target model's own implementation. No Agent, no Flow, no Harness — just a pure model selector.&lt;/p&gt;

&lt;p&gt;That differs from the common "AI gateway" approach where routing, retries, fallback, caching, and rate limiting all fuse into one big blob. Solon AI draws the line clearly: &lt;strong&gt;routing is routing, execution is execution&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.noear&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;solon-ai-router&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;4.1.0&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  2. Basic Usage
&lt;/h2&gt;

&lt;p&gt;Register candidate models, wrap them with a strategy, then send prompts like a normal &lt;code&gt;ChatModel&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;ChatModelRouter&lt;/span&gt; &lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ChatModelRouter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Arrays&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asList&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ChatModelRoute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"fast"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Fast, cheap model for routine Q&amp;amp;A"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fastModel&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ChatModelRoute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"reasoning"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Model for complex analysis and multi-step reasoning"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reasoningModel&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RoundRobinRoutingStrategy&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

&lt;span class="nc"&gt;ChatResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Analyze this concurrency issue"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="no"&gt;F&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;ChatModelRoute&lt;/code&gt; takes four arguments: &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt; (used later by the LLM classifier), &lt;code&gt;weight&lt;/code&gt; (must be &amp;gt; 0), and the &lt;code&gt;chatModel&lt;/code&gt; instance.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ChatModelRouter&lt;/code&gt; mirrors the four &lt;code&gt;ChatModel&lt;/code&gt; entry points, so migration cost is near zero:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;systemMessage&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userMessage&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"user message"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One subtle detail: &lt;strong&gt;routing happens at &lt;code&gt;prompt(...)&lt;/code&gt; time&lt;/strong&gt;. Even if you only create the request and never call &lt;code&gt;call()&lt;/code&gt; or &lt;code&gt;stream()&lt;/code&gt;, one round-robin slot has already been consumed. Don't hoard &lt;code&gt;prompt()&lt;/code&gt; outside a loop.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Four Built-in Strategies, From Dumb to Smart
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1) RoundRobinRoutingStrategy
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RoundRobinRoutingStrategy&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cycles through candidates in registration order. Backed by an &lt;code&gt;AtomicLong&lt;/code&gt; cursor — lock-free, thread-safe, shareable across threads. Perfect for a pool of equivalent models (e.g., same spec, multiple accounts) to spread quota evenly.&lt;/p&gt;

&lt;h3&gt;
  
  
  2) WeightedRoundRobinRoutingStrategy
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WeightedRoundRobinRoutingStrategy&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Smooth weighted round-robin over &lt;code&gt;ChatModelRoute.getWeight()&lt;/code&gt; — the same algorithm nginx uses. With a 5:1 weight ratio, requests spread out evenly instead of "5 hits on A, then 1 on B", which is friendlier to rate-limit windows.&lt;/p&gt;

&lt;p&gt;A design decision worth noting: once used, a strategy instance &lt;strong&gt;pins the candidate IDs, order, and weights&lt;/strong&gt;. If the candidate list topology changes afterwards (added/removed candidates, changed weights), it throws &lt;code&gt;RoutingException&lt;/code&gt; instead of quietly running with the new config. Configuration drift should be visible, not swallowed.&lt;/p&gt;

&lt;h3&gt;
  
  
  3) RuleBasedRoutingStrategy
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;RuleBasedRoutingStrategy&lt;/span&gt; &lt;span class="n"&gt;strategy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RuleBasedRoutingStrategy&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Arrays&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asList&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;RoutingRule&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"reasoning"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPrompt&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getUserContent&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"analyze"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}),&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;RoutingRule&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"fast"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// catch-all: keep it last&lt;/span&gt;
&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rules execute in registration order; &lt;strong&gt;the first match wins&lt;/strong&gt;. &lt;code&gt;RoutingContext&lt;/code&gt; exposes the &lt;code&gt;Prompt&lt;/code&gt;, so you can branch on message content, attributes, or anything else — tenant tier, task type, message length.&lt;/p&gt;

&lt;p&gt;Two semantics you must know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If no rule matches, the strategy &lt;strong&gt;throws &lt;code&gt;RoutingException&lt;/code&gt; immediately&lt;/strong&gt;. It won't guess a default model for you — add an explicit catch-all rule if every request needs a home;&lt;/li&gt;
&lt;li&gt;A predicate that throws a &lt;code&gt;RuntimeException&lt;/code&gt; gets wrapped in a &lt;code&gt;RoutingException&lt;/code&gt; and propagated, never silently skipped.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4) SmartRoutingStrategy — an LLM as the Dispatcher
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;ChatModelRouter&lt;/span&gt; &lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ChatModelRouter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Arrays&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asList&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ChatModelRoute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"fast"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Routine Q&amp;amp;A"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fastModel&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ChatModelRoute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"reasoning"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Complex reasoning"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reasoningModel&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SmartRoutingStrategy&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;classifierModel&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="nc"&gt;ChatResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Analyze this concurrency issue"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="no"&gt;F&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The most interesting one: a &lt;strong&gt;dedicated, ordinary ChatModel acts as the classifier&lt;/strong&gt;. It reads the candidates' &lt;code&gt;description&lt;/code&gt; fields plus the original messages, then emits a structured &lt;code&gt;RoutingDecision&lt;/code&gt; (with &lt;code&gt;routeId&lt;/code&gt; + &lt;code&gt;reasoning&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Look inside &lt;code&gt;SmartRoutingStrategy&lt;/code&gt; and the classifier prompt is refreshingly plain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You are a routing classifier. Select only one candidate route id
and explain the reason.

Candidate routes:
- fast: Routine Q&amp;amp;A
- reasoning: Complex reasoning
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The implementation leans on Solon AI's structured output: &lt;code&gt;options.outputSchema(RoutingDecision.class)&lt;/code&gt; constrains the classifier to a valid shape, then &lt;code&gt;message.toBean(RoutingDecision.class)&lt;/code&gt; deserializes it, then it validates that &lt;code&gt;routeId&lt;/code&gt; is non-empty, &lt;code&gt;reasoning&lt;/code&gt; is non-empty, and the id is a registered candidate. &lt;strong&gt;Any failed step fails the request immediately — it never proceeds to a business model with uncertainty.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is why &lt;code&gt;ChatModelRoute.description&lt;/code&gt; is required — it's the model's "business card" shown to the classifier. How well you write it directly determines routing accuracy.&lt;/p&gt;

&lt;p&gt;The cost is equally clear: &lt;strong&gt;one extra model call per request&lt;/strong&gt; (latency included). Use a small, fast classifier — e.g., a local 1.5B model via Ollama — not your flagship reasoning model.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Explicit Routing: The Escape Hatch
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;Prompt&lt;/code&gt; can &lt;strong&gt;bypass the strategy&lt;/strong&gt; via a fixed attribute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Prompt&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Prompt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Summarize this"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attrPut&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatModelRouter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ATTR_ROUTE_ID&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"fast"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Semantics stay deliberate: the explicit id must be a registered, non-empty string; &lt;strong&gt;an unknown or invalid id throws &lt;code&gt;RoutingException&lt;/code&gt; and never falls back to the configured strategy&lt;/strong&gt;. This makes "let advanced users pick the model" a clean feature — no more silent model swaps the user never asked for.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Error Semantics: No Magic, Only Determinism
&lt;/h2&gt;

&lt;p&gt;List every failure path of &lt;code&gt;ChatModelRouter.prompt()&lt;/code&gt; and you see the module's full personality:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Empty decision / empty routeId / unknown candidate&lt;/td&gt;
&lt;td&gt;Throws &lt;code&gt;RoutingException&lt;/code&gt; immediately&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No rule matched (rule strategy)&lt;/td&gt;
&lt;td&gt;Throws &lt;code&gt;RoutingException&lt;/code&gt; immediately&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Classifier failed / invalid output / unknown candidate&lt;/td&gt;
&lt;td&gt;Throws &lt;code&gt;RoutingException&lt;/code&gt;; &lt;strong&gt;business models are never called&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invalid explicit route id&lt;/td&gt;
&lt;td&gt;Throws &lt;code&gt;RoutingException&lt;/code&gt;; &lt;strong&gt;no fallback to the strategy&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Target model &lt;code&gt;call()&lt;/code&gt;/&lt;code&gt;stream()&lt;/code&gt; throws&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Original type and propagation preserved&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One sentence: &lt;strong&gt;the Router provides no default candidate, no failover, no silent fallback.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In production, that's a life-saver. The most common AI disaster isn't "the request failed" — it's "the request quietly succeeded some other way": degraded to a model the user never approved, silently switched to one 10× more expensive, or misclassified with nobody knowing. Solon AI makes every routing failure loud and hands the degradation decision back to business code. Need a fallback? Catch &lt;code&gt;RoutingException&lt;/code&gt; and write your own — three lines of code, but the control is yours.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Choosing a Strategy
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Spread load/quota across equivalent models&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RoundRobinRoutingStrategy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Split traffic by ratio (e.g., 3:1)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WeightedRoundRobinRoutingStrategy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deterministic business rules (VIP routing, long-input routing)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RuleBasedRoutingStrategy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rules can't enumerate everything; let the model decide&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SmartRoutingStrategy&lt;/code&gt; (with a small classifier)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User manually picks the model&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ATTR_ROUTE_ID&lt;/code&gt; explicit routing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;They compose too: put deterministic rules first, then nest smart routing as the catch-all for the long tail.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;solon-ai-router&lt;/code&gt; is 6 public classes that decompose a real engineering problem — multi-model selection — cleanly: &lt;strong&gt;single responsibility, explicit failure, pluggable strategies&lt;/strong&gt;. It doesn't aspire to be an AI gateway; it does model selection, and does it without surprises.&lt;/p&gt;

&lt;p&gt;That's the taste the Solon ecosystem keeps showing — restrained, but never compromising.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Links&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Solon AI repo: &lt;a href="https://github.com/opensolon/solon-ai" rel="noopener noreferrer"&gt;https://github.com/opensolon/solon-ai&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Solon website: &lt;a href="https://solon.noear.org" rel="noopener noreferrer"&gt;https://solon.noear.org&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Getting started with Solon AI: &lt;a href="https://solon.noear.org/article/learn-solon-ai" rel="noopener noreferrer"&gt;https://solon.noear.org/article/learn-solon-ai&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;All APIs and behaviors in this post were verified against the solon-ai repo's &lt;code&gt;solon-ai-router&lt;/code&gt; module source (since 4.1).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>ai</category>
      <category>solon</category>
      <category>llm</category>
    </item>
    <item>
      <title>What Makes a Coding Agent Trustworthy? A Look at SolonCode's Design Choices</title>
      <dc:creator>Solon Framework</dc:creator>
      <pubDate>Mon, 28 Sep 2026 01:58:44 +0000</pubDate>
      <link>https://dev.to/solonjava/what-makes-a-coding-agent-trustworthy-a-look-at-soloncodes-design-choices-4pek</link>
      <guid>https://dev.to/solonjava/what-makes-a-coding-agent-trustworthy-a-look-at-soloncodes-design-choices-4pek</guid>
      <description>&lt;p&gt;Every week there's a new coding agent, and every week someone asks the same question in a slightly different tone: &lt;em&gt;can I actually trust this thing with my codebase?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It's a fair question. A coding agent reads your source, runs commands in your shell, and — increasingly — edits files and opens pull requests on its own. That's a lot of access to hand to a black box. So instead of arguing about which agent is "smartest," I want to talk about a less glamorous property: &lt;strong&gt;trust&lt;/strong&gt;. What does it actually take for a coding agent to earn it?&lt;/p&gt;

&lt;p&gt;I'll use &lt;a href="https://github.com/opensolon/soloncode" rel="noopener noreferrer"&gt;SolonCode&lt;/a&gt; — an open-source coding agent built in Java on top of &lt;a href="https://github.com/opensolon/solon-ai" rel="noopener noreferrer"&gt;Solon AI&lt;/a&gt; — as a concrete reference, not because it's the only good answer, but because its design happens to line up with a checklist I think is worth having. Take the checklist with you and hold &lt;em&gt;any&lt;/em&gt; agent to it, including this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  A trust checklist for coding agents
&lt;/h2&gt;

&lt;p&gt;Here are the five questions I ask before I let an agent near a real repo.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Can I read the source?&lt;/td&gt;
&lt;td&gt;You can't trust what you can't inspect.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where does my code go?&lt;/td&gt;
&lt;td&gt;Every network hop is a place your code can leak.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Am I locked into one vendor?&lt;/td&gt;
&lt;td&gt;Lock-in quietly removes your ability to walk away.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can I control what it does?&lt;/td&gt;
&lt;td&gt;An agent that acts without review is a liability, not a tool.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can I undo a mistake?&lt;/td&gt;
&lt;td&gt;Autonomy is only safe when it's reversible.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Let's go through them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Can I read the source?
&lt;/h2&gt;

&lt;p&gt;The strongest form of trust is the kind you don't have to take on faith. If the agent is open source, you (or your security team) can read exactly how it builds prompts, what it sends over the wire, and where it stores things.&lt;/p&gt;

&lt;p&gt;SolonCode is MIT-licensed and fully open source — the CLI, the Web UI, and the desktop client are all in the open. That means the interesting questions ("what exactly gets sent to the model?", "does it phone home?") are answerable by reading code, not by trusting a marketing page.&lt;/p&gt;

&lt;p&gt;This is the part that "purity" really comes down to for me: not a vibe, but the fact that there's nothing you &lt;em&gt;can't&lt;/em&gt; look at.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Where does my code go?
&lt;/h2&gt;

&lt;p&gt;A coding agent has to send &lt;em&gt;something&lt;/em&gt; to a model to be useful. The question is what else happens along the way — telemetry, analytics, background uploads.&lt;/p&gt;

&lt;p&gt;SolonCode runs locally. You start it from your own machine in whichever form you like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# terminal (CLI)&lt;/span&gt;
soloncode cli

&lt;span class="c"&gt;# browser (Web UI)&lt;/span&gt;
soloncode web 0

&lt;span class="c"&gt;# or the desktop IDE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent process lives on your box, works in your workspace, and talks directly to the model endpoint you configured. There's no mandatory middle-tier service that your code has to pass through first. For teams with source that legally cannot leave the building, that distinction is the whole ballgame.&lt;/p&gt;

&lt;p&gt;There's a more fundamental angle worth stating on its own: &lt;strong&gt;even if it wanted your code, it has no motive to take it and nowhere to use it.&lt;/strong&gt; A lot of the worry assumes the vendor is quietly harvesting your code to train its own model — but that assumption has a precondition: the vendor needs a first-party model or cloud business for your data to be worth anything to it. SolonCode isn't that kind of player. It has no hosted foundation model of its own, and no official cloud API that your requests are forced to route through — it's a local client that hands the work off to &lt;em&gt;the model you chose&lt;/em&gt;. No in-house model to feed means no incentive to scrape user code for training; no mandatory relay means no pipe where your data could be siphoned off. It can't technically, and it has no reason to commercially — and those two things together are more reassuring than any "we promise not to collect" ever could be.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Am I locked into one vendor?
&lt;/h2&gt;

&lt;p&gt;A lot of agents are welded to a single model provider. That's convenient right up until pricing changes, a better model ships elsewhere, or your employer mandates a specific vendor.&lt;/p&gt;

&lt;p&gt;SolonCode is provider-agnostic. You configure models yourself — through &lt;strong&gt;Settings → LLM&lt;/strong&gt; in the Web UI — and point it at whatever you're allowed to use: a hosted API, an OpenAI-compatible endpoint, or a local model. Because it's built on Solon AI, swapping the underlying model is a configuration change, not a migration.&lt;/p&gt;

&lt;p&gt;The practical value: the day a cheaper or smarter model shows up, you switch a setting instead of switching tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Can I control what it does?
&lt;/h2&gt;

&lt;p&gt;This is the one people underestimate until an agent runs a command they didn't expect. Trust isn't "the agent is always right" — it's "I decide how much rope it gets."&lt;/p&gt;

&lt;p&gt;SolonCode makes the autonomy level an explicit choice. Its work modes include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Approval execution&lt;/strong&gt; — the agent proposes actions; you approve before anything runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic editing&lt;/strong&gt; — for when you've built up trust on a task and want it to move.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read-only planning&lt;/strong&gt; — it can analyze and plan, but cannot touch your files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Goal execution&lt;/strong&gt; — persistent, longer-horizon autonomous work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point isn't that one mode is "correct." It's that &lt;em&gt;you&lt;/em&gt; pick the risk level per task, instead of the tool picking for you. Reviewing a hairy migration? Read-only planning. Renaming a variable across ten files? Let it run.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Can I undo a mistake?
&lt;/h2&gt;

&lt;p&gt;Even a careful agent will occasionally do the wrong thing. What matters is whether that's a shrug or a disaster.&lt;/p&gt;

&lt;p&gt;SolonCode keeps persistent session history with &lt;strong&gt;rewind and redo&lt;/strong&gt;, recoverable workspace checkpoints, and safe deletion. If a run goes sideways, you roll back to a known-good checkpoint instead of reconstructing your working tree from memory. Autonomy and reversibility are two sides of the same coin: the more freedom you give an agent, the more you want a clean undo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trust is a property you can check
&lt;/h2&gt;

&lt;p&gt;None of these five points are about which agent writes the cleverest code. They're about whether you can &lt;em&gt;verify&lt;/em&gt; how it behaves — read its source, see where your code goes, keep your model options open, control its autonomy, and undo its mistakes.&lt;/p&gt;

&lt;p&gt;That's a better lens than "which one is smartest," because smart-but-opaque is exactly the combination that gets you into trouble. An agent you can inspect, run locally, point at any model, gate with approvals, and roll back is one you can reason about — and reasoning about your tools is the whole job.&lt;/p&gt;

&lt;p&gt;SolonCode is one implementation that scores well on this checklist, and it's open source, so you can confirm every claim above by reading the code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/opensolon/soloncode" rel="noopener noreferrer"&gt;https://github.com/opensolon/soloncode&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Built on Solon AI: &lt;a href="https://github.com/opensolon/solon-ai" rel="noopener noreferrer"&gt;https://github.com/opensolon/solon-ai&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://solon.noear.org/article/soloncode" rel="noopener noreferrer"&gt;https://solon.noear.org/article/soloncode&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run the checklist against whatever agent you use. The goal isn't loyalty to a tool — it's keeping the power to check, choose, and undo in your own hands.&lt;/p&gt;

</description>
      <category>java</category>
      <category>ai</category>
      <category>opensource</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Build a RAG Pipeline in Java with Solon AI: From Raw Text to Grounded Answers</title>
      <dc:creator>Solon Framework</dc:creator>
      <pubDate>Mon, 28 Sep 2026 00:55:54 +0000</pubDate>
      <link>https://dev.to/solonjava/build-a-rag-pipeline-in-java-with-solon-ai-from-raw-text-to-grounded-answers-36lk</link>
      <guid>https://dev.to/solonjava/build-a-rag-pipeline-in-java-with-solon-ai-from-raw-text-to-grounded-answers-36lk</guid>
      <description>&lt;p&gt;A large language model only knows what it saw during training. Ask it about your internal wiki, last week's release notes, or a customer's support history, and it will either shrug or — worse — confidently make something up. Retrieval-Augmented Generation (RAG) is the standard fix: before the model answers, you retrieve the relevant facts from your own data and hand them over as context.&lt;/p&gt;

&lt;p&gt;Most RAG tutorials are written in Python. This one is in Java, using &lt;strong&gt;Solon AI&lt;/strong&gt; — the AI module of the &lt;a href="https://solon.noear.org" rel="noopener noreferrer"&gt;Solon&lt;/a&gt; framework. We'll go from a pile of raw text to a grounded answer in a single, runnable file, then look at how to swap the in-memory store for Redis, load real documents, and filter by metadata.&lt;/p&gt;

&lt;p&gt;Every API in this article was checked against the &lt;code&gt;solon-ai&lt;/code&gt; source, so the method names are the real ones — not the hallucinated ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The moving parts
&lt;/h2&gt;

&lt;p&gt;A RAG pipeline in Solon AI is built from five small pieces:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EmbeddingModel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;model&lt;/td&gt;
&lt;td&gt;turn text into vectors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Document&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;data&lt;/td&gt;
&lt;td&gt;a chunk of content + metadata + score&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DocumentSplitter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;util&lt;/td&gt;
&lt;td&gt;slice long text into chunks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Repository&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;store&lt;/td&gt;
&lt;td&gt;save vectors, search by similarity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ChatModel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;model&lt;/td&gt;
&lt;td&gt;generate the final answer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The flow is always the same: &lt;strong&gt;split → embed → store&lt;/strong&gt;, then at query time &lt;strong&gt;embed the question → search → augment the prompt → generate&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add the dependency
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;solon-ai&lt;/code&gt; aggregate pulls in the core plus the OpenAI / Ollama / DashScope / Gemini / Anthropic dialects, which is everything you need for a minimal pipeline.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.noear&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;solon-ai&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="c"&gt;&amp;lt;!-- replace with the latest stable release from Maven Central --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;4.1.0&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;EmbeddingModel&lt;/code&gt;, &lt;code&gt;ChatModel&lt;/code&gt;, &lt;code&gt;InMemoryRepository&lt;/code&gt;, &lt;code&gt;Document&lt;/code&gt;, and the built-in splitters all live in &lt;code&gt;solon-ai-core&lt;/code&gt;, so a minimal RAG needs no extra vector-store or loader modules.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole pipeline in one file
&lt;/h2&gt;

&lt;p&gt;Here is an end-to-end example. It uses a local &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; server so you can run it without any API keys — point the URLs and models at OpenAI or DashScope if you prefer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.ai.chat.ChatModel&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.ai.chat.ChatResponse&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.ai.chat.message.ChatMessage&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.ai.embedding.EmbeddingModel&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.ai.rag.Document&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.ai.rag.RepositoryStorable&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.ai.rag.repository.InMemoryRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.ai.rag.splitter.SplitterPipeline&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.ai.rag.splitter.TokenSizeTextSplitter&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.ai.rag.util.QueryCondition&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.util.Arrays&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.util.List&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MiniRag&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// 1) Embedding model: turns text into vectors&lt;/span&gt;
        &lt;span class="nc"&gt;EmbeddingModel&lt;/span&gt; &lt;span class="n"&gt;embeddingModel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;EmbeddingModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://127.0.0.1:11434/api/embed"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ollama"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// or "openai" / "dashscope"&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"bge-m3"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;batchSize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="c1"&gt;// 2) In-memory vector store (must be given an EmbeddingModel)&lt;/span&gt;
        &lt;span class="nc"&gt;RepositoryStorable&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InMemoryRepository&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embeddingModel&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// 3) Your source content&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;rawText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Solon is a Java application development framework. "&lt;/span&gt;
                &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"It offers its own IoC/AOP container, a lightweight web layer, "&lt;/span&gt;
                &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"and Solon AI for building LLM applications. "&lt;/span&gt;
                &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"Solon starts fast and has a small memory footprint, "&lt;/span&gt;
                &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"which makes it a good fit for GraalVM native images."&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

        &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Document&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;rawDocs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Arrays&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asList&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Document&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawText&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"About Solon"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://solon.noear.org"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// 4) Split long text into chunks (default chunkSize = 500 tokens)&lt;/span&gt;
        &lt;span class="nc"&gt;SplitterPipeline&lt;/span&gt; &lt;span class="n"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SplitterPipeline&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TokenSizeTextSplitter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Document&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;split&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawDocs&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// 5) Store — save() embeds each chunk in batches automatically&lt;/span&gt;
        &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// 6) Retrieve&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;question&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"What is Solon and why is it good for native images?"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="nc"&gt;QueryCondition&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;QueryCondition&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;                    &lt;span class="c1"&gt;// default is 4&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;similarityThreshold&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="no"&gt;D&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// default is 0.4&lt;/span&gt;
        &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Document&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;search&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// 7) Chat model for generation&lt;/span&gt;
        &lt;span class="nc"&gt;ChatModel&lt;/span&gt; &lt;span class="n"&gt;chatModel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ChatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://127.0.0.1:11434/api/chat"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ollama"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// or "openai" / "dashscope"&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"qwen2.5"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="c1"&gt;// 8) Augment the prompt with retrieved context, then ask&lt;/span&gt;
        &lt;span class="nc"&gt;ChatMessage&lt;/span&gt; &lt;span class="n"&gt;userMsg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ChatMessage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofUserAugment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;ChatResponse&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userMsg&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getError&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getError&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getContent&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire pipeline. Let's unpack the parts that matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the API is actually doing
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;Document&lt;/code&gt; — plain data, no magic factory
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;Document&lt;/code&gt; holds an &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;content&lt;/code&gt;, a &lt;code&gt;Map&amp;lt;String, Object&amp;gt; metadata&lt;/code&gt;, a transient &lt;code&gt;score&lt;/code&gt; (filled in during search), and a &lt;code&gt;float[] embedding&lt;/code&gt;. There is &lt;strong&gt;no&lt;/strong&gt; &lt;code&gt;Document.of(...)&lt;/code&gt; factory — you use the constructor and chain setters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Document&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"some content"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"About Solon"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://solon.noear.org"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"category"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"framework"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Splitting is a pipeline
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;SplitterPipeline&lt;/code&gt; lets you chain splitters with &lt;code&gt;next(...)&lt;/code&gt;. The built-in &lt;code&gt;TokenSizeTextSplitter&lt;/code&gt; cuts on token count (default 500, backed by jtokkit's &lt;code&gt;CL100K_BASE&lt;/code&gt;), and &lt;code&gt;RegexTextSplitter&lt;/code&gt; cuts on a pattern (default &lt;code&gt;\n\n&lt;/code&gt;). Chain them when you want "split on blank lines, then cap each piece at N tokens".&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;save()&lt;/code&gt; embeds for you
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;RepositoryStorable.save(...)&lt;/code&gt; is the write path — note the name is &lt;code&gt;save&lt;/code&gt;, not &lt;code&gt;insert&lt;/code&gt; or &lt;code&gt;store&lt;/code&gt;. Internally it batches by &lt;code&gt;embeddingModel.batchSize()&lt;/code&gt; and calls &lt;code&gt;embed(...)&lt;/code&gt; for each batch, so you never touch vectors by hand. There's also &lt;code&gt;asyncSave(...)&lt;/code&gt;, &lt;code&gt;deleteById(String...)&lt;/code&gt;, and &lt;code&gt;existsById(String)&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;QueryCondition&lt;/code&gt; controls retrieval
&lt;/h3&gt;

&lt;p&gt;The constructor takes the query string; everything else is chained:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;limit(int)&lt;/code&gt; — how many chunks to return (default &lt;strong&gt;4&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;similarityThreshold(double)&lt;/code&gt; — minimum score to keep (default &lt;strong&gt;0.4&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;filterExpression(String)&lt;/code&gt; — metadata filter (more on this below)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Augmentation: two ways
&lt;/h3&gt;

&lt;p&gt;The interesting one is &lt;code&gt;ChatMessage.ofUserAugment(question, context)&lt;/code&gt;. It wraps your question and the retrieved documents into a single user message using this template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{question}

 Now: {current time}

 References: {context}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the model gets the question, the current time (handy for "latest" style questions), and the references — all in one shot.&lt;/p&gt;

&lt;p&gt;If you'd rather not orchestrate the search yourself, &lt;code&gt;Repository&lt;/code&gt; has a one-liner that does &lt;em&gt;search + wrap&lt;/em&gt; together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// search(question) + ofUserAugment(...) in a single call&lt;/span&gt;
&lt;span class="nc"&gt;ChatMessage&lt;/span&gt; &lt;span class="n"&gt;augmented&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;promptAugment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;ChatResponse&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;augmented&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getContent&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Going beyond in-memory
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Swap in a real vector store
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;InMemoryRepository&lt;/code&gt; is great for demos, but for production you'll want a persistent store. Solon AI ships repositories for Redis, Milvus, Qdrant, pgvector, Elasticsearch, OpenSearch, Chroma, Weaviate, MySQL, MariaDB, and more — each is a separate Maven module (&lt;code&gt;solon-ai-repo-redis&lt;/code&gt;, &lt;code&gt;solon-ai-repo-milvus&lt;/code&gt;, …).&lt;/p&gt;

&lt;p&gt;Redis, for example, uses a builder that takes the embedding model plus a Jedis client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// dependency: org.noear:solon-ai-repo-redis&lt;/span&gt;
&lt;span class="nc"&gt;RedisRepository&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RedisRepository&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embeddingModel&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jedisClient&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;indexName&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"my_docs"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Repository&lt;/code&gt; interface is identical across stores, so everything downstream — &lt;code&gt;save&lt;/code&gt;, &lt;code&gt;search&lt;/code&gt;, &lt;code&gt;QueryCondition&lt;/code&gt; — stays exactly the same. Swapping the store never touches your retrieval code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Load real documents
&lt;/h3&gt;

&lt;p&gt;For real content you rarely start from a &lt;code&gt;String&lt;/code&gt;. The &lt;code&gt;TextLoader&lt;/code&gt; (from &lt;code&gt;File&lt;/code&gt;, &lt;code&gt;URI&lt;/code&gt;, &lt;code&gt;URL&lt;/code&gt;, &lt;code&gt;byte[]&lt;/code&gt;, or a stream) lives in the core; separate modules add &lt;code&gt;MarkdownLoader&lt;/code&gt;, &lt;code&gt;PdfLoader&lt;/code&gt;, &lt;code&gt;HtmlSimpleLoader&lt;/code&gt; (note: not &lt;code&gt;HtmlLoader&lt;/code&gt;), &lt;code&gt;WordLoader&lt;/code&gt;, &lt;code&gt;ExcelLoader&lt;/code&gt;, and &lt;code&gt;PptLoader&lt;/code&gt;. Every loader returns &lt;code&gt;List&amp;lt;Document&amp;gt;&lt;/code&gt;, so it drops straight into the splitter → store flow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Filter by metadata with SnEL
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;QueryCondition.filterExpression(String)&lt;/code&gt; accepts a &lt;strong&gt;SnEL&lt;/strong&gt; expression and narrows the search to documents whose metadata matches — vector similarity &lt;em&gt;and&lt;/em&gt; a structured filter, in one query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;QueryCondition&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;QueryCondition&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filterExpression&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"category == 'framework' AND year &amp;gt;= 2024"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood the string is parsed by &lt;code&gt;SnEL.parse&lt;/code&gt; into a portable expression tree, and each vector store rewrites that tree into its own native filter syntax. (If you want the full story on how one SnEL expression targets Redis, Milvus, Qdrant, and pgvector, I wrote about that &lt;a href="https://dev.to/solonjava/one-expression-many-databases-a-tour-of-solons-snel-engine-java-24j3"&gt;in an earlier post&lt;/a&gt;.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Let the agent retrieve on its own
&lt;/h3&gt;

&lt;p&gt;Everything above is "retrieve first, then ask". Solon AI also supports &lt;em&gt;agentic&lt;/em&gt; RAG via &lt;code&gt;RepositoryTool&lt;/code&gt;, which wraps a &lt;code&gt;Repository&lt;/code&gt; as a callable tool (&lt;code&gt;@ToolMapping("repository_query")&lt;/code&gt;). Hand it to a &lt;code&gt;ChatModel&lt;/code&gt; and the model decides &lt;em&gt;when&lt;/em&gt; to search — useful for multi-turn conversations where not every question needs a lookup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;ChatModel&lt;/span&gt; &lt;span class="n"&gt;chatModel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ChatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiUrl&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"openai"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"gpt-4o-mini"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defaultToolAdd&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RepositoryTool&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The mental model is small: &lt;strong&gt;split → embed → store&lt;/strong&gt;, then &lt;strong&gt;embed → search → augment → generate&lt;/strong&gt;. Solon AI gives you each step as a plain, composable piece — &lt;code&gt;Document&lt;/code&gt;, &lt;code&gt;DocumentSplitter&lt;/code&gt;, &lt;code&gt;Repository&lt;/code&gt;, &lt;code&gt;EmbeddingModel&lt;/code&gt;, &lt;code&gt;ChatModel&lt;/code&gt; — with no framework ceremony. Start with &lt;code&gt;InMemoryRepository&lt;/code&gt; to prove the flow, switch to Redis or pgvector when you need persistence, and reach for &lt;code&gt;filterExpression&lt;/code&gt; and &lt;code&gt;RepositoryTool&lt;/code&gt; when your retrieval gets more demanding.&lt;/p&gt;

&lt;p&gt;Same interface all the way down, no rewrite when you scale up. That's the part I like.&lt;/p&gt;

&lt;p&gt;Project: &lt;a href="https://solon.noear.org" rel="noopener noreferrer"&gt;solon.noear.org&lt;/a&gt; · Source: &lt;a href="https://github.com/opensolon/solon-ai" rel="noopener noreferrer"&gt;github.com/opensolon/solon-ai&lt;/a&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>java</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>SnEL Inside the Container: How Solon Wires Expressions Into Config, Beans, Cache and Data Sources (Java)</title>
      <dc:creator>Solon Framework</dc:creator>
      <pubDate>Wed, 23 Sep 2026 12:58:42 +0000</pubDate>
      <link>https://dev.to/solonjava/snel-inside-the-container-how-solon-wires-expressions-into-config-beans-cache-and-data-sources-3iab</link>
      <guid>https://dev.to/solonjava/snel-inside-the-container-how-solon-wires-expressions-into-config-beans-cache-and-data-sources-3iab</guid>
      <description>&lt;p&gt;In an earlier post I looked at &lt;a href="https://dev.to/solonjava/one-expression-many-databases-a-tour-of-solons-snel-engine-java-24j3"&gt;Solon's SnEL engine as a standalone, portable filter DSL&lt;/a&gt; — the trick where one expression string gets rewritten into Redis / Milvus / Qdrant filter syntax. A few readers asked the obvious follow-up: &lt;em&gt;that's nice for a library, but where does the expression engine actually show up when I'm running a normal Solon app?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The answer is: almost everywhere the container touches a string that might need to be resolved at runtime. Solon threads SnEL through configuration, dependency injection, conditional beans, method caching, dynamic data sources, and even validation messages. This post is a tour of those integration points, with the exact source paths so you can go read the wiring yourself.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;APIs below are checked against the &lt;code&gt;solon&lt;/code&gt; core and &lt;code&gt;solon-projects&lt;/code&gt; source. The relevant helper is &lt;code&gt;org.noear.solon.core.util.SnelUtil&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The dispatcher: &lt;code&gt;#{...}&lt;/code&gt; vs &lt;code&gt;${...}&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Almost every container-side use goes through one small helper, &lt;code&gt;SnelUtil.evalTmpl&lt;/code&gt;. It's worth reading because it explains a naming convention you'll see all over Solon:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// org.noear.solon.core.util.SnelUtil&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;evalTmpl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;tmpl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmpl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;indexOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"#{"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;SnEL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;evalTmpl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmpl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// new: full SnEL sub-expression&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmpl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;indexOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;TmplUtil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmpl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// legacy: simple property template&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tmpl&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;                             &lt;span class="c1"&gt;// no marker: return as-is&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the rule of thumb across the whole framework:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;${...}&lt;/code&gt; — a &lt;strong&gt;property placeholder&lt;/strong&gt;. Pull a value from config by key, optionally &lt;code&gt;${key:default}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;#{...}&lt;/code&gt; — a &lt;strong&gt;SnEL evaluation placeholder&lt;/strong&gt;. The braces contain a real sub-expression that gets evaluated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep that distinction in your head; it's the key to reading everything below.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Resolving placeholders anywhere
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AppContext&lt;/code&gt; exposes the resolver directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// org.noear.solon.core.AppContext&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;resolvePlaceholders&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;expr&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;SnelUtil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;evalTmpl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expr&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cfg()&lt;/code&gt; is the application configuration (&lt;code&gt;app.yml&lt;/code&gt; / properties). So anywhere you have the context, you can expand a template against config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Solon&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resolvePlaceholders&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"jdbc:mysql://${db.host:localhost}:${db.port:3306}/app"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// or evaluate a real expression against config&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;banner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Solon&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resolvePlaceholders&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"app is #{'v' + ${app.version:1.0}}"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Injection: config values and evaluated values
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@Inject&lt;/code&gt; is where the &lt;code&gt;${}&lt;/code&gt; / &lt;code&gt;#{}&lt;/code&gt; split really pays off. The container inspects the string and branches (see &lt;code&gt;BeanContainer&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// @Inject("${xxx}") or @Inject("${xxx:def}") — inject a config value (single value)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;startsWith&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;findConfigKey&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;beanInjectConfig&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vh&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name2&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ... plus auto-refresh binding when the config key changes&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// @Inject("#{...}") — evaluate a SnEL template, then convert to the field type&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;startsWith&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"#{"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SnelUtil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;evalTmpl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="n"&gt;val2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ConvertUtil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vh&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getType&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;vh&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getGenericType&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;vh&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val2&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MyService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// classic config injection, with default + hot-refresh on field injection&lt;/span&gt;
    &lt;span class="nd"&gt;@Inject&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${app.title:Solon}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// evaluated expression, converted to the target type&lt;/span&gt;
    &lt;span class="nd"&gt;@Inject&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"#{${server.port:8080} + 1}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;adminPort&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the nesting in the second one: &lt;code&gt;${server.port:8080}&lt;/code&gt; is a property reference &lt;em&gt;inside&lt;/em&gt; a &lt;code&gt;#{...}&lt;/code&gt; expression, so the config value is pulled first and then the arithmetic runs. And because &lt;code&gt;${}&lt;/code&gt; field injection registers a config-change listener, those values can hot-refresh when the config source updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Conditional beans with &lt;code&gt;@Condition(onExpression=...)&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is my favorite container-side use. &lt;code&gt;@Condition&lt;/code&gt; decides whether a bean/configuration is created at all, and &lt;code&gt;onExpression&lt;/code&gt; is a SnEL expression evaluated against config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// org.noear.solon.core.util.ConditionUtil&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;testExpression&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AppContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;expr&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SnelUtil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expr&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// true/false directly&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Assert&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isNotEmpty&lt;/span&gt;&lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// non-empty string&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;                                        &lt;span class="c1"&gt;// otherwise: non-null&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The annotation documents the intended shape (note it uses &lt;code&gt;${}&lt;/code&gt; property refs inside the expression):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Condition&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;onExpression&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"${env} == 'pro'"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Configuration&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProdOnlyConfig&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;MetricsReporter&lt;/span&gt; &lt;span class="nf"&gt;reporter&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// combine conditions&lt;/span&gt;
&lt;span class="nd"&gt;@Condition&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;onExpression&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"${feature.cache} == 'on' &amp;amp;&amp;amp; ${env} != 'test'"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CacheWarmup&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the evaluation result is coerced sensibly (Boolean → itself, String → non-empty, other → non-null), you can also write &lt;code&gt;@Condition(onExpression = "${some.key}")&lt;/code&gt; to mean "only if this key has a value." &lt;code&gt;@Condition&lt;/code&gt; also has the type-safe &lt;code&gt;onClass&lt;/code&gt; / &lt;code&gt;onBean&lt;/code&gt; / &lt;code&gt;onMissingBean&lt;/code&gt; knobs — &lt;code&gt;onExpression&lt;/code&gt; is the escape hatch for config-driven logic. (The older &lt;code&gt;onProperty&lt;/code&gt; attribute is deprecated in 3.6 in favor of &lt;code&gt;onExpression&lt;/code&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Method caching: templating keys from arguments
&lt;/h2&gt;

&lt;p&gt;Solon's declarative cache (&lt;code&gt;solon-data&lt;/code&gt;) builds cache keys and tags from a template that can reference &lt;strong&gt;method arguments by name&lt;/strong&gt;. The interceptor runs each attribute through &lt;code&gt;SnelUtil.evalTmpl&lt;/code&gt; with an &lt;code&gt;Invocation&lt;/code&gt;-backed context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// org.noear.solon.data.cache.CacheExecutorImp (abridged)&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anno&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Utils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;InvKeys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;buildByInv&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inv&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// auto key from args when none given&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SnelUtil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;evalTmpl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inv&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// expand #{...} against method args (+ result)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The context that backs &lt;code&gt;inv&lt;/code&gt; exposes each argument by its &lt;code&gt;@Param&lt;/code&gt; name, plus a special &lt;code&gt;result&lt;/code&gt; key for the return value (used by &lt;code&gt;@CachePut&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// org.noear.solon.core.util.SnelUtil.InvocationContext#apply&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="s"&gt;"result"&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;inv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="n"&gt;rst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;argsAsMap&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// argument-by-name&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the real usage looks like this (straight from the framework's own test service):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Cache&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"#{id}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@CachePut&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"user_#{user.id}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@CacheRemove&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"user_#{id}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tags&lt;/code&gt; works the same way and supports multiple comma-separated values, each templated. The &lt;code&gt;Cache&lt;/code&gt; annotation's own Javadoc gives the canonical example: &lt;code&gt;user_#{user_id}&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Dynamic data source routing with &lt;code&gt;@DynamicDs&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The dynamic data source interceptor picks a datasource name by evaluating its template — so you can route by a method argument at call time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// org.noear.solon.data.dynamicds.DynamicDsInterceptor&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;dsName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SnelUtil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;evalTmpl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;anno&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;inv&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;DynamicDsKey&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dsName&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;inv:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;invoke&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderDao&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// route to a datasource chosen by the tenant argument&lt;/span&gt;
    &lt;span class="nd"&gt;@DynamicDs&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"#{tenant}_db"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tenant"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same &lt;code&gt;Invocation&lt;/code&gt; context as caching, so argument names are in scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Validation messages: a per-instance &lt;code&gt;SnelParser&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Not everything uses the static &lt;code&gt;SnEL&lt;/code&gt; facade. When you need a configured parser — a custom marker, a bounded cache — you instantiate &lt;code&gt;SnelParser&lt;/code&gt; directly. The i18n validation failure handler does exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// org.noear.solon.validation.ValidatorFailureHandlerI18n&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;SnelParser&lt;/span&gt; &lt;span class="no"&gt;SNEL&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// custom marker '#','{' and a cache capacity&lt;/span&gt;
&lt;span class="no"&gt;SNEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SnelParser&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cacheCapacity&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="sc"&gt;'#'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="sc"&gt;'{'&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;SNEL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasMarker&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;SNEL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forTmpl&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
              &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
              &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;I18nUtil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here the "variable lookup" isn't a map at all — it's a lambda that resolves each key through the i18n message bundle. That's the general shape of SnEL: the context is any &lt;code&gt;Function&amp;lt;String, Object&amp;gt;&lt;/code&gt;, so you can back it with config, a POJO, method arguments, or a message catalog.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do it this way?
&lt;/h2&gt;

&lt;p&gt;Two things stand out once you see all six use sites together.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;one engine, consistent semantics.&lt;/strong&gt; Config injection, conditional beans, cache keys, and datasource routing all share the same &lt;code&gt;${}&lt;/code&gt;/&lt;code&gt;#{}&lt;/code&gt; convention and the same evaluator. Learn it once and it reads the same everywhere.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;safe by omission.&lt;/strong&gt; SnEL has no object instantiation and no control flow, so exposing it to &lt;code&gt;app.yml&lt;/code&gt; or annotation strings doesn't open a scripting hole. It's an &lt;em&gt;evaluator&lt;/em&gt;, not a scripting language — which is precisely why the container can lean on it so heavily.&lt;/p&gt;

&lt;p&gt;If you came from the vector-DB filter angle in the last post, this is the other half of the picture: the same tiny 40KB engine that rewrites database filters is also the quiet workhorse behind Solon's configuration and DI.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;solon-expression: &lt;a href="https://github.com/opensolon/solon-expression" rel="noopener noreferrer"&gt;https://github.com/opensolon/solon-expression&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Solon: &lt;a href="https://github.com/opensolon/solon" rel="noopener noreferrer"&gt;https://github.com/opensolon/solon&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;SnEL docs: &lt;a href="https://solon.noear.org/article/learn-solon-snel" rel="noopener noreferrer"&gt;https://solon.noear.org/article/learn-solon-snel&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Have you wired an expression engine into your own container or config layer? I'm curious how you kept it from turning into a security footgun.&lt;/p&gt;

</description>
      <category>javaopensourcewebdevbackend</category>
    </item>
    <item>
      <title>One Expression, Many Databases: A Tour of Solon's SnEL Engine (Java)</title>
      <dc:creator>Solon Framework</dc:creator>
      <pubDate>Wed, 23 Sep 2026 01:54:57 +0000</pubDate>
      <link>https://dev.to/solonjava/one-expression-many-databases-a-tour-of-solons-snel-engine-java-24j3</link>
      <guid>https://dev.to/solonjava/one-expression-many-databases-a-tour-of-solons-snel-engine-java-24j3</guid>
      <description>&lt;p&gt;Most Java projects reach for an expression engine sooner or later — a rule check here, a dynamic config value there, a filter that a user types at runtime. The usual suspects are heavyweight: they pull in scripting runtimes, allow arbitrary code, and become a security review headache.&lt;/p&gt;

&lt;p&gt;Solon takes a more restrained path with &lt;strong&gt;SnEL&lt;/strong&gt; (Solon Expression Language). It is a pure-Java, zero-dependency engine that compiles to a little over 40KB, and it works standalone — you can drop it into Spring Boot, Vert.x, jFinal, or a plain &lt;code&gt;main&lt;/code&gt; method. But the part I find genuinely clever is how Solon AI reuses the &lt;em&gt;parsed expression tree&lt;/em&gt; as a portable DSL and rewrites it into the native filter syntax of Redis, Milvus, Qdrant, pgvector, and friends.&lt;/p&gt;

&lt;p&gt;This post walks through SnEL from "hello world" to that vector-database trick.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;All APIs below are checked against the &lt;code&gt;solon-expression&lt;/code&gt; and &lt;code&gt;solon-ai&lt;/code&gt; source. SnEL ships in Solon 3.1.1+.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Adding the dependency
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.noear&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;solon-expression&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SnEL&lt;/code&gt; is a shortcut interface over &lt;code&gt;SnelEvaluator.getInstance()&lt;/code&gt;. You can use the static helpers directly, or instantiate an evaluator when you need isolation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The philosophy: an evaluator, not a scripting language
&lt;/h2&gt;

&lt;p&gt;SnEL is deliberately constrained, and the constraints &lt;em&gt;are&lt;/em&gt; the feature:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It always returns a single value — it is an &lt;em&gt;evaluation&lt;/em&gt; expression, not a statement block.&lt;/li&gt;
&lt;li&gt;Variables come only from the context you pass in; there is no &lt;code&gt;new Xxx()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;One expression, no &lt;code&gt;;&lt;/code&gt;. No &lt;code&gt;if&lt;/code&gt;/&lt;code&gt;for&lt;/code&gt;/loops — it is not a scripting engine.&lt;/li&gt;
&lt;li&gt;Field, property, and method access can nest deeply, but only &lt;code&gt;public&lt;/code&gt; members are reachable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last set of rules is exactly what makes it safe to expose to config files or, carefully, to end-user input: there is no way to instantiate arbitrary classes or run control flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evaluating expressions
&lt;/h2&gt;

&lt;p&gt;The context is just a &lt;code&gt;Map&lt;/code&gt; (or any &lt;code&gt;Function&amp;lt;String, Object&amp;gt;&lt;/code&gt;). Values flow in by name.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.expression.snel.SnEL&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Constants and arithmetic — no context needed&lt;/span&gt;
&lt;span class="nc"&gt;SnEL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"1 + 1"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// 2&lt;/span&gt;
&lt;span class="nc"&gt;SnEL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"1 * (1 + 2)"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// 3&lt;/span&gt;
&lt;span class="nc"&gt;SnEL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"'hello ' + 'world!'"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "hello world!"&lt;/span&gt;
&lt;span class="nc"&gt;SnEL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"[1, 2, 3, -4]"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// a list&lt;/span&gt;

&lt;span class="c1"&gt;// Variables from a context map&lt;/span&gt;
&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"solon"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"list"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Arrays&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asList&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="nc"&gt;SnEL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"name.length()"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// 5  (method call)&lt;/span&gt;
&lt;span class="nc"&gt;SnEL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"name.length() &amp;gt; 2 OR true"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;&lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;span class="nc"&gt;SnEL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"list[0] == 1"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;             &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The syntax at a glance
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Constants&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;1&lt;/code&gt;, &lt;code&gt;'name'&lt;/code&gt;, &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;[1,2,3]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Variables&lt;/td&gt;
&lt;td&gt;&lt;code&gt;name&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Map / list access&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;map['name']&lt;/code&gt;, &lt;code&gt;list[0]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Property / method&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;user.name&lt;/code&gt;, &lt;code&gt;user['name']&lt;/code&gt;, &lt;code&gt;order.getUser()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arithmetic&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;+&lt;/code&gt; &lt;code&gt;-&lt;/code&gt; &lt;code&gt;*&lt;/code&gt; &lt;code&gt;/&lt;/code&gt; &lt;code&gt;%&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Comparison&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;&lt;/code&gt; &lt;code&gt;&amp;lt;=&lt;/code&gt; &lt;code&gt;&amp;gt;&lt;/code&gt; &lt;code&gt;&amp;gt;=&lt;/code&gt; &lt;code&gt;==&lt;/code&gt; &lt;code&gt;!=&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;like&lt;/code&gt; / &lt;code&gt;in&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;name LIKE 'so'&lt;/code&gt;, &lt;code&gt;vip IN ['l3','l4']&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ternary&lt;/td&gt;
&lt;td&gt;&lt;code&gt;age &amp;gt; 18 ? 'adult' : 'minor'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logical&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AND&lt;/code&gt; &lt;code&gt;OR&lt;/code&gt; &lt;code&gt;NOT&lt;/code&gt; (aliases &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; `&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safe navigation&lt;/td&gt;
&lt;td&gt;{% raw %}&lt;code&gt;user?.name&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default (Elvis)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;user.name ?: 'noear'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Property reference&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;${user.name}&lt;/code&gt;, &lt;code&gt;${user.name:noear}&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static type call&lt;/td&gt;
&lt;td&gt;&lt;code&gt;T(java.lang.Integer).valueOf(45)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A couple of rules worth remembering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keywords are &lt;strong&gt;uppercase&lt;/strong&gt;: &lt;code&gt;LIKE&lt;/code&gt;, &lt;code&gt;NOT LIKE&lt;/code&gt;, &lt;code&gt;IN&lt;/code&gt;, &lt;code&gt;NOT IN&lt;/code&gt;, &lt;code&gt;AND&lt;/code&gt;, &lt;code&gt;OR&lt;/code&gt;, &lt;code&gt;NOT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Numeric literals follow Java: &lt;code&gt;1.1F&lt;/code&gt;, &lt;code&gt;1.1D&lt;/code&gt;, &lt;code&gt;1L&lt;/code&gt;, &lt;code&gt;1.1&lt;/code&gt; (double), &lt;code&gt;1&lt;/code&gt; (int).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is a heftier condition, exactly the kind of rule you would otherwise hand-code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"age"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"salary"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4000&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"isMarried"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"label"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"aa"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"title"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ee"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"vip"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"l3"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;expr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"(((age &amp;gt; 18 AND salary &amp;lt; 5000) OR (NOT isMarried)) "&lt;/span&gt;
            &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"AND label IN ['aa','bb'] AND title NOT IN ['cc','dd']) "&lt;/span&gt;
            &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"OR vip == 'l3'"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;pass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;SnEL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expr&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Beans as context, and virtual variables
&lt;/h2&gt;

&lt;p&gt;A plain &lt;code&gt;Function&amp;lt;String, Object&amp;gt;&lt;/code&gt; cannot expose a POJO's properties. &lt;code&gt;EnhanceContext&lt;/code&gt; bridges that gap and adds the &lt;code&gt;root&lt;/code&gt; / &lt;code&gt;this&lt;/code&gt; virtual variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.expression.context.EnhanceContext&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// has a public getUserId()&lt;/span&gt;

&lt;span class="nc"&gt;SnEL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"userId &amp;gt; 12 ? 'A' : 'B'"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EnhanceContext&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="nc"&gt;SnEL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"root.userId &amp;gt; 12 ? 'A' : 'B'"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EnhanceContext&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// When the whole target is the value itself:&lt;/span&gt;
&lt;span class="nc"&gt;SnEL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"root ? 'A' : 'B'"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EnhanceContext&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// "A"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;EnhanceContext&lt;/code&gt; also lets you bind application properties, so &lt;code&gt;${...}&lt;/code&gt; property references resolve against config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;SnEL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${user.name:solon}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Solon&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="nc"&gt;SnEL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"'Hello ' + ${user.name:solon}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Solon&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Template expressions
&lt;/h2&gt;

&lt;p&gt;For string templating, SnEL uses two placeholders:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;#{...}&lt;/code&gt; — an &lt;em&gt;evaluation&lt;/em&gt; placeholder (a full sub-expression)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;${...}&lt;/code&gt; / &lt;code&gt;${...:default}&lt;/code&gt; — a &lt;em&gt;property&lt;/em&gt; placeholder
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;SnEL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;evalTmpl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"a val is #{a}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;SnEL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;evalTmpl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sum val is #{a + b}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;SnEL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;evalTmpl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sum is #{a + b}, c prop is ${demo.c:c}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not a toy. Solon AI uses &lt;code&gt;evalTmpl&lt;/code&gt; in real code paths: system/user message templates (&lt;code&gt;SystemMessageTemplate&lt;/code&gt;, &lt;code&gt;UserMessageTemplate&lt;/code&gt;), tool descriptions (&lt;code&gt;@ToolMapping&lt;/code&gt; descriptions run through &lt;code&gt;SnEL.evalTmpl&lt;/code&gt;), and even DDL loaders that build SQL for RAG ingestion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interesting part: one tree, many query languages
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SnEL.parse(expr)&lt;/code&gt; does not just give you an answer — it returns an &lt;code&gt;Expression&lt;/code&gt; &lt;strong&gt;tree&lt;/strong&gt;. Because that tree is a neutral structure, it can be walked and rewritten. Solon defines a &lt;code&gt;Transformer&amp;lt;Boolean, String&amp;gt;&lt;/code&gt; interface for exactly this, and Solon AI ships a &lt;code&gt;FilterTransformer&lt;/code&gt; for each vector store.&lt;/p&gt;

&lt;p&gt;In practice, you write your metadata filter &lt;em&gt;once&lt;/em&gt; as a SnEL string, and each repository turns it into its own dialect. Here is where it enters the RAG path — &lt;code&gt;QueryCondition&lt;/code&gt; parses the string into a tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// solon-ai: QueryCondition&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;QueryCondition&lt;/span&gt; &lt;span class="nf"&gt;filterExpression&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;filterExpression&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filterExpression&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SnEL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filterExpression&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So your application code stays portable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;QueryCondition&lt;/span&gt; &lt;span class="n"&gt;cond&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;QueryCondition&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"What is Solon?"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filterExpression&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"category == 'framework' AND year &amp;gt;= 2020"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Document&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;search&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cond&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the same tree — &lt;code&gt;LogicalNode(AND) → ComparisonNode(eq), ComparisonNode(gte)&lt;/code&gt; — gets rewritten per backend. The Redis transformer, for example, walks the node types and emits Redis Search syntax:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// solon-ai-repo-redis: FilterTransformer (abridged)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filterExpression&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;ComparisonNode&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ComparisonNode&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ComparisonNode&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;filterExpression&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getOperator&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nl"&gt;eq:&lt;/span&gt;  &lt;span class="c1"&gt;// @field:{value}&lt;/span&gt;
            &lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getLeft&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;  &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;":"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;  &lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRight&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nl"&gt;gte:&lt;/span&gt; &lt;span class="c1"&gt;// @field:[value +inf]&lt;/span&gt;
            &lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getLeft&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;  &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;":["&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRight&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;" +inf]"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filterExpression&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;LogicalNode&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// AND -&amp;gt; space, OR -&amp;gt; " | ", NOT -&amp;gt; "-"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;category == 'framework' AND year &amp;gt;= 2020&lt;/code&gt; becomes something like &lt;code&gt;(@category:{framework} @year:[2020 +inf])&lt;/code&gt; for Redis, while the Milvus, Qdrant, pgvector, Elasticsearch, and Chroma transformers each produce their own native filter for the very same input. Swap your vector store and the filter code doesn't move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the tree by hand
&lt;/h2&gt;

&lt;p&gt;If you would rather not go through string parsing, &lt;code&gt;ConditionBuilder&lt;/code&gt; assembles the same tree programmatically — handy when the condition is generated from a UI or another rule system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.expression.snel.ConditionBuilder&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.expression.Expression&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;ConditionBuilder&lt;/span&gt; &lt;span class="n"&gt;cb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ConditionBuilder&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// (age &amp;gt; 18 AND salary &amp;lt; 5000) OR (isMarried == false)&lt;/span&gt;
&lt;span class="nc"&gt;Expression&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;or&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;and&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;gt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"age"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"salary"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="o"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eq&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"isMarried"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;context:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output of &lt;code&gt;ConditionBuilder&lt;/code&gt; is the same &lt;code&gt;Expression&amp;lt;Boolean&amp;gt;&lt;/code&gt; type that &lt;code&gt;SnEL.parse&lt;/code&gt; yields, so it flows into &lt;code&gt;QueryCondition.filterExpression(...)&lt;/code&gt; and every &lt;code&gt;Transformer&lt;/code&gt; exactly the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use it
&lt;/h2&gt;

&lt;p&gt;SnEL fits nicely when you want:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic config / conditions&lt;/strong&gt; without embedding a scripting engine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A safe evaluator&lt;/strong&gt; for semi-trusted input — no object construction, no control flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A portable filter DSL&lt;/strong&gt; that you can retarget across data stores (its reason for existing inside Solon AI).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is intentionally &lt;em&gt;not&lt;/em&gt; a general-purpose scripting language. If you need loops, assignments, or object instantiation, reach for something else. But for the "evaluate this condition against this context" job — which is 90% of what people actually want — its small surface area and predictable behavior are the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;SnEL is a good example of Solon's design taste: keep the core tiny, keep it safe by omission, and make the output composable. The fact that a 40KB evaluator doubles as the intermediate representation for cross-database vector filtering is the kind of leverage you get from picking the right abstraction.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;solon-expression: &lt;a href="https://github.com/opensolon/solon-expression" rel="noopener noreferrer"&gt;https://github.com/opensolon/solon-expression&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs (SnEL series): &lt;a href="https://solon.noear.org/article/learn-solon-snel" rel="noopener noreferrer"&gt;https://solon.noear.org/article/learn-solon-snel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Solon: &lt;a href="https://github.com/opensolon/solon" rel="noopener noreferrer"&gt;https://github.com/opensolon/solon&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have used SnEL — or a similar "expression tree as DSL" pattern — I'd love to hear how it held up in your project.&lt;/p&gt;

</description>
      <category>database</category>
      <category>java</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Tool Calling in Java, the Simple Way: Building an AI Agent with Solon AI</title>
      <dc:creator>Solon Framework</dc:creator>
      <pubDate>Tue, 22 Sep 2026 13:49:40 +0000</pubDate>
      <link>https://dev.to/solonjava/tool-calling-in-java-the-simple-way-building-an-ai-agent-with-solon-ai-2mfk</link>
      <guid>https://dev.to/solonjava/tool-calling-in-java-the-simple-way-building-an-ai-agent-with-solon-ai-2mfk</guid>
      <description>&lt;p&gt;Large language models are great at reasoning over text, but on their own they can't check today's weather, query your database, or hit an internal API. &lt;strong&gt;Tool calling&lt;/strong&gt; (a.k.a. function calling) is what bridges that gap: you expose plain methods to the model, and it decides when to call them.&lt;/p&gt;

&lt;p&gt;If you live in the Java world, you might assume this requires a heavyweight stack. It doesn't. &lt;a href="https://solon.noear.org" rel="noopener noreferrer"&gt;Solon AI&lt;/a&gt; — the AI module of the &lt;a href="https://github.com/opensolon/solon" rel="noopener noreferrer"&gt;Solon&lt;/a&gt; framework — lets you turn an ordinary Java method into an LLM tool with a single annotation. This post walks through a complete, runnable example.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Solon is an independent, full-scenario Java application framework. It is &lt;strong&gt;not&lt;/strong&gt; Spring and has its own IoC/AOP, plugins, and annotations. Nothing here depends on Spring.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;

&lt;p&gt;A tiny "assistant" that can answer questions like &lt;em&gt;"What's the weather in Hangzhou, and what time is it there?"&lt;/em&gt; The model will call two Java methods we provide — a weather lookup and a clock — and weave the results into a natural-language answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Add the dependency
&lt;/h2&gt;

&lt;p&gt;Solon AI ships an aggregate artifact (&lt;code&gt;solon-ai&lt;/code&gt;) that bundles the core plus the built-in dialects. The &lt;code&gt;openai&lt;/code&gt; dialect (the default) is compatible with a wide range of providers — DeepSeek, Qwen, GLM, Kimi, GPT, and others.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.noear&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;solon-ai&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;4.0.3&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Define your tools
&lt;/h2&gt;

&lt;p&gt;A "tool" is just a method annotated with &lt;code&gt;@ToolMapping&lt;/code&gt;. The &lt;code&gt;description&lt;/code&gt; tells the model what the tool does; &lt;code&gt;@Param&lt;/code&gt; describes each argument. Solon AI generates the JSON schema and handles the call dispatch for you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.ai.annotation.ToolMapping&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.ai.annotation.Param&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.time.LocalTime&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AssistantTools&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@ToolMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Get the current weather for a city"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;getWeather&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"City name"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// In a real app this would call a weather API.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;city&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;": sunny, 14°C"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@ToolMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Get the current local time"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"City name"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;city&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" local time: "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;LocalTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it — no interfaces to implement, no schema to hand-write. The method's name, parameters, and descriptions become the tool contract exposed to the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Build the ChatModel and register the tools
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ChatModel.of(...)&lt;/code&gt; gives you a fluent builder. Point it at your provider's chat endpoint, set the model, then attach your tools with &lt;code&gt;defaultToolAdd&lt;/code&gt;. Passing an object makes Solon AI scan it for &lt;code&gt;@ToolMapping&lt;/code&gt; methods automatically.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.ai.chat.ChatModel&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.ai.chat.ChatResponse&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Demo&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;ChatModel&lt;/span&gt; &lt;span class="n"&gt;chatModel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ChatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://api.deepseek.com/v1/chat/completions"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getenv&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"DEEPSEEK_API_KEY"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="c1"&gt;// never hard-code keys&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"openai"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// openai-compatible dialect&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"deepseek-chat"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defaultToolAdd&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AssistantTools&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="c1"&gt;// register both tools&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="nc"&gt;ChatResponse&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chatModel&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"What's the weather in Hangzhou, and what time is it there?"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getContent&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood, Solon AI runs the full tool-calling loop: it sends your prompt plus the tool definitions, receives the model's request to call &lt;code&gt;getWeather&lt;/code&gt; and &lt;code&gt;getTime&lt;/code&gt;, invokes your Java methods, feeds the results back, and returns the final composed answer. You just call &lt;code&gt;.call()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A possible output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The weather in Hangzhou is sunny at 14°C, and the local time there is 09:42.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Prefer configuration over code (optional)
&lt;/h2&gt;

&lt;p&gt;Hard-coding endpoints is fine for a demo, but in a real Solon app you'd externalize this to &lt;code&gt;app.yml&lt;/code&gt; (Solon's config file — not &lt;code&gt;application.yml&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;solon.ai.chat&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;assistant&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;apiUrl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.deepseek.com/v1/chat/completions"&lt;/span&gt;
    &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${DEEPSEEK_API_KEY}"&lt;/span&gt;
    &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openai"&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deepseek-chat"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then bind it with a config bean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.annotation.Bean&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.annotation.Configuration&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.annotation.Inject&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.ai.chat.ChatConfig&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.noear.solon.ai.chat.ChatModel&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@Configuration&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AiConfig&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ChatModel&lt;/span&gt; &lt;span class="nf"&gt;chatModel&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Inject&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${solon.ai.chat.assistant}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;ChatConfig&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ChatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defaultToolAdd&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AssistantTools&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;ChatModel&lt;/code&gt; is a managed component you can &lt;code&gt;@Inject&lt;/code&gt; anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few useful extras
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Streaming&lt;/strong&gt;: swap &lt;code&gt;.call()&lt;/code&gt; for &lt;code&gt;.stream()&lt;/code&gt; to get a &lt;code&gt;Flux&amp;lt;ChatResponse&amp;gt;&lt;/code&gt; (requires &lt;code&gt;solon-web-rx&lt;/code&gt;). Great for typing-effect UIs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Return direct&lt;/strong&gt;: set &lt;code&gt;@ToolMapping(returnDirect = true)&lt;/code&gt; when a tool's result should be returned verbatim, skipping a second LLM pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reasoning control&lt;/strong&gt;: on the builder you can call &lt;code&gt;.reasoning_effort("high")&lt;/code&gt; or &lt;code&gt;.thinking(true)&lt;/code&gt; — Solon AI maps these to each provider's native format (OpenAI, Anthropic, Gemini, DashScope, and more), so your code stays provider-agnostic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP&lt;/strong&gt;: if your tools live in an external &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; server, register an &lt;code&gt;McpClientProvider&lt;/code&gt; via the same &lt;code&gt;defaultToolAdd(...)&lt;/code&gt; — the model can't tell the difference.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Tool calling in Java doesn't have to be verbose. With Solon AI you annotate a method, register the object, and call &lt;code&gt;.prompt(...).call()&lt;/code&gt; — the framework handles schema generation, the multi-turn tool loop, and cross-provider quirks. From here it's a short hop to RAG pipelines, MCP servers, and multi-agent setups, all in the same lightweight framework.&lt;/p&gt;

&lt;p&gt;Links:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Website: &lt;a href="https://solon.noear.org" rel="noopener noreferrer"&gt;https://solon.noear.org&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/opensolon/solon" rel="noopener noreferrer"&gt;https://github.com/opensolon/solon&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building AI features on the JVM, give it a try — and let me know what you build.&lt;/p&gt;

</description>
      <category>webdev</category>
    </item>
    <item>
      <title>Giving an AI Agent a Real Sandbox: Filesystem and Network Jail, in Java</title>
      <dc:creator>Solon Framework</dc:creator>
      <pubDate>Mon, 14 Sep 2026 00:53:49 +0000</pubDate>
      <link>https://dev.to/solonjava/giving-an-ai-agent-a-real-sandbox-filesystem-and-network-jail-in-java-28bi</link>
      <guid>https://dev.to/solonjava/giving-an-ai-agent-a-real-sandbox-filesystem-and-network-jail-in-java-28bi</guid>
      <description>&lt;p&gt;Ask a coding agent to run a build, and you have just handed a language model the ability to &lt;code&gt;cat ~/.ssh/id_rsa&lt;/code&gt;. Prompt-level instructions like "do not read sensitive files" are not a security boundary — they are a suggestion to a stochastic process. If the agent executes commands on your machine, the only control that actually holds is the one the operating system enforces.&lt;/p&gt;

&lt;p&gt;That is the problem &lt;a href="https://solon.noear.org/article/learn-solon-ai" rel="noopener noreferrer"&gt;Solon AI&lt;/a&gt;'s new &lt;code&gt;solon-ai-sandbox&lt;/code&gt; module solves. It is a Java port of Claude Code's &lt;code&gt;sandbox-runtime&lt;/code&gt;, and it wraps agent-issued commands in real filesystem and network isolation — on macOS, Linux, and Windows.&lt;/p&gt;

&lt;p&gt;All code below was verified against the &lt;code&gt;solon-ai-sandbox&lt;/code&gt; source in the Solon AI 4.1.x tree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just run the agent in Docker?
&lt;/h2&gt;

&lt;p&gt;Containers are the usual answer, and for a server-side agent they are the right one. But the agents people actually run interactively — the ones editing their working copy of a repo — are not in a container. They are on a laptop, in a terminal, one &lt;code&gt;bash&lt;/code&gt; call away from everything the user can touch.&lt;/p&gt;

&lt;p&gt;Booting a VM or a container per command is too slow for that loop, and it breaks the agent's access to the working tree you wanted it to edit. What you want is a &lt;em&gt;narrow&lt;/em&gt; boundary: keep the agent in the project directory, let it reach the registries and package mirrors the build needs, and make everything else fail closed — without a container runtime in the picture.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;solon-ai-sandbox&lt;/code&gt; does exactly that, using each platform's native facility:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;macOS&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sandbox-exec&lt;/code&gt; with a generated Seatbelt profile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Linux&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bubblewrap&lt;/code&gt; (&lt;code&gt;bwrap&lt;/code&gt;), plus &lt;code&gt;socat&lt;/code&gt; for the network bridge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Windows&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;srt-win.exe&lt;/code&gt; with a WFP filter layer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The module depends on nothing but &lt;code&gt;solon-ai-core&lt;/code&gt;, so pulling it in does not drag a container runtime along with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The entry point is one class
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SandboxManager&lt;/code&gt; is a final class with static methods — there is one sandbox per process, and there is one place to configure it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.noear&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;solon-ai-sandbox&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;${solon-ai.version}&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Initialization takes a runtime config and an optional interactive callback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;SandboxManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initialize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;askCallback&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And wrapping a command is a single call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;wrapped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SandboxManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;wrapWithSandbox&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"git status"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;Process&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Runtime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRuntime&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;[]{&lt;/span&gt;&lt;span class="s"&gt;"/bin/bash"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"-c"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wrapped&lt;/span&gt;&lt;span class="o"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On macOS the result is a &lt;code&gt;sandbox-exec -p '&amp;lt;seatbelt profile&amp;gt;'&lt;/code&gt; invocation; on Linux it is a &lt;code&gt;bwrap&lt;/code&gt; invocation with the appropriate bind mounts and namespaces. Your code never branches on the platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filesystem: two different policies, on purpose
&lt;/h2&gt;

&lt;p&gt;Reads and writes use opposite defaults, and understanding why is the key to configuring this correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Writes are &lt;code&gt;allow-only&lt;/code&gt;.&lt;/strong&gt; The default is &lt;em&gt;deny everything&lt;/em&gt;. You list the paths the agent may write, and &lt;code&gt;denyWrite&lt;/code&gt; punches holes back out of that list. The manager always adds the paths a process genuinely cannot function without — &lt;code&gt;/dev/*&lt;/code&gt;, temp directories, and so on — so you are not fighting the OS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reads are &lt;code&gt;deny-then-allow-back&lt;/code&gt;.&lt;/strong&gt; The default is &lt;em&gt;allow&lt;/em&gt;, because breaking every read on the machine would break the compiler, the JVM, and half of userspace. Instead you name the regions to protect, and &lt;code&gt;allowRead&lt;/code&gt; re-opens specific paths inside them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;FilesystemConfig&lt;/span&gt; &lt;span class="n"&gt;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FilesystemConfig&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;Arrays&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asList&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"~/.ssh"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"~/.aws"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;// denyRead&lt;/span&gt;
    &lt;span class="nc"&gt;Collections&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;emptyList&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;             &lt;span class="c1"&gt;// allowRead&lt;/span&gt;
    &lt;span class="nc"&gt;Arrays&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asList&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/tmp"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"."&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;          &lt;span class="c1"&gt;// allowWrite&lt;/span&gt;
    &lt;span class="nc"&gt;Arrays&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asList&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;".git"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;               &lt;span class="c1"&gt;// denyWrite&lt;/span&gt;
    &lt;span class="kc"&gt;false&lt;/span&gt;                                &lt;span class="c1"&gt;// allowGitConfig&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that as: the agent may write only under the current working directory and &lt;code&gt;/tmp&lt;/code&gt;, must never write into &lt;code&gt;.git&lt;/code&gt;, and may not read your SSH or AWS credentials even though reads are otherwise open.&lt;/p&gt;

&lt;p&gt;Two details that matter in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An empty &lt;code&gt;allowWrite&lt;/code&gt; list is the strictest possible setting&lt;/strong&gt; — it means no writes at all beyond the mandatory system paths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't write into &lt;code&gt;.git&lt;/code&gt;.&lt;/strong&gt; A corrupted or maliciously rewritten Git directory is a nasty persistence vector, which is why &lt;code&gt;denyWrite&lt;/code&gt; on &lt;code&gt;.git&lt;/code&gt; shows up in the security defaults, and why &lt;code&gt;.git/config&lt;/code&gt; gets its own &lt;code&gt;allowGitConfig&lt;/code&gt; switch (default &lt;code&gt;false&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Network: a proxy, not a firewall rule
&lt;/h2&gt;

&lt;p&gt;Network isolation here is implemented with a local HTTP and SOCKS5 forward proxy. The sandboxed process is pointed at it via environment variables, and the module decides per request whether to let the connection through.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;NetworkConfig&lt;/span&gt; &lt;span class="n"&gt;network&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NetworkConfig&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;Arrays&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asList&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"api.openai.com"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"*.github.com"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// allowlist&lt;/span&gt;
    &lt;span class="nc"&gt;Arrays&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asList&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"telemetry.example.com"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;          &lt;span class="c1"&gt;// denylist&lt;/span&gt;
    &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Domain patterns support wildcards like &lt;code&gt;*.github.com&lt;/code&gt;, and &lt;code&gt;HostUtils&lt;/code&gt; normalizes IPv4, IPv6, and hostnames so that matching is not trivially bypassed by writing an address a different way.&lt;/p&gt;

&lt;p&gt;The reason to use a proxy instead of a kernel firewall rule is &lt;strong&gt;live updates&lt;/strong&gt;. The proxies read the configuration on every request, so this takes effect immediately, on already-running agent processes, with no rebind and no port change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;SandboxManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;updateConfig&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;newConfig&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare that with filesystem rules, which are &lt;em&gt;not&lt;/em&gt; live: on macOS the rules are baked into the Seatbelt profile when the command is wrapped, and on Windows they have to be explicitly re-stamped. To change filesystem restrictions you must &lt;code&gt;reset()&lt;/code&gt; and &lt;code&gt;initialize()&lt;/code&gt; again. Know which knob is hot and which one requires a restart.&lt;/p&gt;

&lt;p&gt;There is also a callback for the case where the allowlist is not the final word:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;SandboxAskCallback&lt;/span&gt; &lt;span class="n"&gt;callback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostPattern&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Allow "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;hostPattern&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getHost&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;":"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;hostPattern&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPort&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"? [y/N]"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Scanner&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;in&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;nextLine&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;trim&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;equalsIgnoreCase&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"y"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It fails closed: if the callback throws, or no configuration covers the request, the connection is denied.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constructor signature changed — watch out for this
&lt;/h2&gt;

&lt;p&gt;Here is a concrete trap. The module's README still shows a &lt;code&gt;SandboxRuntimeConfig&lt;/code&gt; with &lt;strong&gt;12&lt;/strong&gt; constructor arguments. The current source declares &lt;strong&gt;13&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;SandboxRuntimeConfig&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SandboxRuntimeConfig&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;network&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;                       &lt;span class="c1"&gt;// NetworkConfig&lt;/span&gt;
    &lt;span class="n"&gt;fs&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;                            &lt;span class="c1"&gt;// FilesystemConfig&lt;/span&gt;
    &lt;span class="n"&gt;ignoredViolations&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;// Map&amp;lt;String, List&amp;lt;String&amp;gt;&amp;gt;&lt;/span&gt;
    &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;                          &lt;span class="c1"&gt;// enableWeakerNestedSandbox&lt;/span&gt;
    &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;                          &lt;span class="c1"&gt;// enableWeakerNetworkIsolation&lt;/span&gt;
    &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;                          &lt;span class="c1"&gt;// allowAppleEvents&lt;/span&gt;
    &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;                          &lt;span class="c1"&gt;// RipgrepConfig&lt;/span&gt;
    &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;                          &lt;span class="c1"&gt;// mandatoryDenySearchDepth&lt;/span&gt;
    &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;                          &lt;span class="c1"&gt;// allowPty&lt;/span&gt;
    &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;                          &lt;span class="c1"&gt;// SeccompConfig&lt;/span&gt;
    &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;                          &lt;span class="c1"&gt;// bwrapPath&lt;/span&gt;
    &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;                          &lt;span class="c1"&gt;// socatPath&lt;/span&gt;
    &lt;span class="kc"&gt;null&lt;/span&gt;                           &lt;span class="c1"&gt;// WindowsConfig  &amp;lt;-- the 13th&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trailing &lt;code&gt;WindowsConfig&lt;/code&gt; parameter is the one the README example is missing, so code copied from it will not compile against 4.1.x. I verified this directly in &lt;code&gt;SandboxRuntimeConfig.java&lt;/code&gt;; the code in this post is written against the source, not the README.&lt;/p&gt;

&lt;h2&gt;
  
  
  Windows needs a different call
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;wrapWithSandbox(String)&lt;/code&gt; returns a shell string, and on Windows it throws instead:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;wrapWithSandbox() returns a shell string and is not supported on Windows. Use SandboxManager.wrapWithSandboxArgv()...&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The argv variant returns &lt;code&gt;{ argv, env }&lt;/code&gt;, where &lt;code&gt;env&lt;/code&gt; carries the full proxy environment the child needs to inherit. On macOS and Linux it still works — it just wraps the string form behind &lt;code&gt;&amp;lt;shell&amp;gt; -c&lt;/code&gt; — so if you want one code path across all three platforms, use &lt;code&gt;wrapWithSandboxArgv&lt;/code&gt; everywhere and spawn with &lt;code&gt;{ shell: false }&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observability: violations are data, not just logs
&lt;/h2&gt;

&lt;p&gt;A blocked operation is an event worth recording. &lt;code&gt;SandboxViolationStore&lt;/code&gt; is a thread-safe, category-keyed store:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;SandboxViolationStore&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SandboxViolationStore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ignoreViolations&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;record&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"network"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"attempted connection to telemetry.example.com:443"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCategories&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;": "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getViolations&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because violations are categorized — &lt;code&gt;file_read&lt;/code&gt;, &lt;code&gt;file_write&lt;/code&gt;, &lt;code&gt;network&lt;/code&gt; — a burst of &lt;code&gt;network&lt;/code&gt; denials from a normally well-behaved agent is a signal worth alerting on. &lt;code&gt;ignoreViolations&lt;/code&gt; suppresses known-noisy entries by substring match, which keeps the signal readable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you ship this: two operational gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Check dependencies at startup, and fail loudly.&lt;/strong&gt; &lt;code&gt;initialize()&lt;/code&gt; refuses to proceed if the platform's dependency is missing, and you should surface that rather than silently degrading to an unsandboxed run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Platform&lt;/span&gt; &lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PlatformDetector&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;detect&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="nc"&gt;SandboxDependencyCheck&lt;/span&gt; &lt;span class="n"&gt;deps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SandboxManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;checkDependencies&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasErrors&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Sandbox unavailable: "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getErrors&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Linux that means &lt;code&gt;bubblewrap&lt;/code&gt; and &lt;code&gt;socat&lt;/code&gt; must be installed (&lt;code&gt;apt install bubblewrap socat&lt;/code&gt;); macOS needs nothing, since &lt;code&gt;sandbox-exec&lt;/code&gt; ships with the OS; Windows needs &lt;code&gt;srt-win.exe&lt;/code&gt; installed once with elevation to set up the WFP layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Call &lt;code&gt;cleanupAfterCommand()&lt;/code&gt; after each command on Linux.&lt;/strong&gt; &lt;code&gt;bwrap&lt;/code&gt; creates empty placeholder files on the &lt;em&gt;host&lt;/em&gt; filesystem when it protects paths that do not exist — &lt;code&gt;~/.bashrc&lt;/code&gt; on a fresh container, for example. They linger after the process exits. The method is a no-op on macOS, and it is also invoked from &lt;code&gt;reset()&lt;/code&gt; and a JVM shutdown hook, but calling it in your command loop avoids accumulating junk.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model
&lt;/h2&gt;

&lt;p&gt;Three rules cover almost all of it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Writes are opt-in, reads are opt-out.&lt;/strong&gt; Configure writes as an explicit allowlist; configure reads as a short list of things worth protecting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network is hot, filesystem is cold.&lt;/strong&gt; Allowlist changes apply to running processes; filesystem changes need a reset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail closed, and fail visibly.&lt;/strong&gt; Missing dependencies and unmatched callbacks should surface as errors, not as an unsandboxed fallback.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The larger point is about where the boundary lives. Once an agent can execute code, "the model was asked nicely" is not a control. &lt;code&gt;solon-ai-sandbox&lt;/code&gt; moves that control down into the mechanism the OS already enforces — Seatbelt, bubblewrap, or the Windows Filtering Platform — and hands you a small, uniform Java API for it.&lt;/p&gt;

&lt;p&gt;For a Java agent stack, that is the difference between a demo and something you let near a real repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Solon AI documentation: &lt;a href="https://solon.noear.org/article/learn-solon-ai" rel="noopener noreferrer"&gt;https://solon.noear.org/article/learn-solon-ai&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Module source (&lt;code&gt;solon-ai-sandbox&lt;/code&gt;), Solon AI 4.1.x tree&lt;/li&gt;
&lt;li&gt;Claude Code &lt;code&gt;sandbox-runtime&lt;/code&gt; (the TypeScript original this module ports)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>java</category>
      <category>security</category>
      <category>opensource</category>
    </item>
    <item>
      <title>MCP Without the Boilerplate: Solon AI's Annotation-Driven Server and Self-Healing Client</title>
      <dc:creator>Solon Framework</dc:creator>
      <pubDate>Tue, 08 Sep 2026 02:23:53 +0000</pubDate>
      <link>https://dev.to/solonjava/mcp-without-the-boilerplate-solon-ais-annotation-driven-server-and-self-healing-client-hg9</link>
      <guid>https://dev.to/solonjava/mcp-without-the-boilerplate-solon-ais-annotation-driven-server-and-self-healing-client-hg9</guid>
      <description>&lt;p&gt;If you have been following this series, you have seen how Solon AI streams chat as semantic events and how it chunks documents by meaning. This time we move from conversation plumbing to capability plumbing: &lt;strong&gt;how Solon AI turns ordinary Java code into MCP (Model Context Protocol) services, and how it consumes remote MCP servers without hand-writing protocol code.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;All code in this post was verified against the Solon AI 4.1.x source tree (&lt;code&gt;solon-ai-mcp&lt;/code&gt; and &lt;code&gt;mcp-core&lt;/code&gt; modules).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why MCP, and why in-process?
&lt;/h2&gt;

&lt;p&gt;MCP standardizes how an LLM application discovers and invokes &lt;strong&gt;tools&lt;/strong&gt;, reads &lt;strong&gt;resources&lt;/strong&gt;, and loads &lt;strong&gt;prompts&lt;/strong&gt; from an external provider. Instead of hard-coding function calls into your prompt pipeline, you point your app at an MCP endpoint — local or remote — and the capability list arrives over the wire.&lt;/p&gt;

&lt;p&gt;Solon AI ships MCP support in two layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mcp-core&lt;/code&gt;&lt;/strong&gt; — a self-contained protocol implementation covering MCP spec revisions from &lt;code&gt;2024-11-05&lt;/code&gt; through &lt;code&gt;2025-11-25&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;solon-ai-mcp&lt;/code&gt;&lt;/strong&gt; — the application-facing layer: an annotation-driven server and a &lt;code&gt;ToolProvider&lt;/code&gt;-compatible client that plugs straight into &lt;code&gt;ChatModel&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The design goal is visible in the dependency direction: the protocol layer knows nothing about Solon AI, and the integration layer adds almost nothing you have to learn.&lt;/p&gt;

&lt;h2&gt;
  
  
  The server: one annotation per capability
&lt;/h2&gt;

&lt;p&gt;Declare an endpoint class with &lt;code&gt;@McpServerEndpoint&lt;/code&gt;, then annotate plain methods:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@McpServerEndpoint&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;mcpEndpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/mcp/sse"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;heartbeatInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"30s"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;McpServerTool&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Tip: enable the -parameters compiler flag,&lt;/span&gt;
    &lt;span class="c1"&gt;// or give every @Param an explicit name.&lt;/span&gt;
    &lt;span class="nd"&gt;@ToolMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"查询天气预报"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;getWeather&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"城市位置"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"晴，14度"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole server. At startup, &lt;code&gt;McpPlugin&lt;/code&gt; scans &lt;code&gt;@McpServerEndpoint&lt;/code&gt; classes and builds an &lt;code&gt;McpServerEndpointProvider&lt;/code&gt; from them. Method-level providers — &lt;code&gt;MethodToolProvider&lt;/code&gt;, &lt;code&gt;MethodResourceProvider&lt;/code&gt;, &lt;code&gt;MethodPromptProvider&lt;/code&gt; — extract &lt;code&gt;@ToolMapping&lt;/code&gt;, &lt;code&gt;@ResourceMapping&lt;/code&gt;, and &lt;code&gt;@PromptMapping&lt;/code&gt; methods and register them with the endpoint's lifecycle. No JSON schemas to maintain by hand, no dispatch switch, no transport wiring.&lt;/p&gt;

&lt;p&gt;Details worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;heartbeatInterval&lt;/code&gt; defaults to &lt;code&gt;"30s"&lt;/code&gt; on the server side.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sseEndpoint()&lt;/code&gt; and &lt;code&gt;messageEndpoint()&lt;/code&gt; are deprecated; the unified &lt;code&gt;mcpEndpoint()&lt;/code&gt; is the way to go.&lt;/li&gt;
&lt;li&gt;There are two hosting models: a &lt;strong&gt;stateful&lt;/strong&gt; host (&lt;code&gt;STREAMABLE&lt;/code&gt; channel) that keeps session state per client, and a &lt;strong&gt;stateless&lt;/strong&gt; one (&lt;code&gt;STREAMABLE_STATELESS&lt;/code&gt;) where every request carries everything it needs — the better fit behind load balancers.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;enableOutputSchema()&lt;/code&gt; can turn on output schema validation for tools that need it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The client: &lt;code&gt;McpClientProvider&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;On the consuming side, one class implements &lt;code&gt;ToolProvider&lt;/code&gt;, &lt;code&gt;ResourceProvider&lt;/code&gt;, and &lt;code&gt;PromptProvider&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;McpClientProvider&lt;/span&gt; &lt;span class="n"&gt;mcpClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;McpClientProvider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://localhost:8081/sse"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="nc"&gt;ChatModel&lt;/span&gt; &lt;span class="n"&gt;chatModel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ChatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chatConfig&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defaultToolAdd&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mcpClient&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="nc"&gt;ChatResponse&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chatModel&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"杭州天气和北京降雨量如何？"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The provider is lazy: the underlying &lt;code&gt;McpAsyncClient&lt;/code&gt; is created on first use, guarded by a lock. From then on, &lt;code&gt;ChatModel&lt;/code&gt; treats MCP tools exactly like local function tools — the model sees them in its tool list, picks one, and Solon AI routes the invocation through &lt;code&gt;callTool(name, args)&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Four channels, one builder
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;McpChannel&lt;/code&gt; defines &lt;code&gt;STDIO&lt;/code&gt;, &lt;code&gt;SSE&lt;/code&gt;, &lt;code&gt;STREAMABLE&lt;/code&gt;, and &lt;code&gt;STREAMABLE_STATELESS&lt;/code&gt;. The builder picks the transport for you:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Channel&lt;/th&gt;
&lt;th&gt;Transport&lt;/th&gt;
&lt;th&gt;Typical use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;STDIO&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;StdioClientTransport&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Launching a local MCP binary as a subprocess&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SSE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WebRxSseClientTransport&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Classic HTTP + server-sent events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;STREAMABLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WebRxStreamableHttpTransport&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Modern streamable HTTP, stateful session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;STREAMABLE_STATELESS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WebRxStreamableHttpTransport&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Stateless streamable HTTP, LB-friendly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For per-call granularity instead of defaults, push the tool list into the prompt options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;chatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"今天杭州的天气情况？"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toolAdd&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mcpClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTools&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;is&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatEventType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TEXT_DELTA&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasText&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;ChatEvent:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getText&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note how this composes with the ChatEvent streaming API from the first post in this series — MCP tools and semantic events are orthogonal layers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Self-healing connections
&lt;/h3&gt;

&lt;p&gt;Network transport code fails in boring, repetitive ways. &lt;code&gt;McpClientProvider&lt;/code&gt; centralizes the retry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;T&lt;/span&gt; &lt;span class="nf"&gt;executeWithRetry&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Function&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;McpAsyncClient&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Mono&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;apply&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;getClient&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;block&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Throwable&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isTransportError&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reset&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;                                 &lt;span class="c1"&gt;// drop the broken client&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;apply&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;getClient&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;block&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;     &lt;span class="c1"&gt;// reconnect, retry once&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;isTransportError&lt;/code&gt; matches &lt;code&gt;McpTransportException&lt;/code&gt;, timeouts, connection refusals, and friends. Protocol-level errors (a tool that returned an error, for example) propagate untouched — retrying those would be wrong.&lt;/p&gt;

&lt;p&gt;If you enable heartbeats, a failed beat doubles the backoff interval on each retry, capped at 10 minutes. Intervals under 5 seconds are rejected outright. And note the asymmetry: the server sends heartbeats every 30s by default, while the client opts in explicitly — a deliberate choice to keep the client quiet unless you ask.&lt;/p&gt;

&lt;h3&gt;
  
  
  Caching and change notification
&lt;/h3&gt;

&lt;p&gt;Listing tools, resources, and prompts over MCP is a round trip you do not want on every prompt. The client caches these lists locally for 30 seconds by default (&lt;code&gt;cacheSeconds&lt;/code&gt;). When the server emits a change notification, the matching cache entry is invalidated — the next listing goes back over the wire. The notification clears the cache; it does not push the new list. Subtle, but it is the difference between "eventually fresh" and "push-updated," and it keeps the client simple.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tool allow-lists and deny-lists
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;allowedTools&lt;/code&gt; and &lt;code&gt;disallowedTools&lt;/code&gt; filter what the client exposes. Filtering applies the allow-list first, then the deny-list — so a tool must pass both gates to reach your model. Handy for exposing a curated subset of a large third-party MCP server.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuration-driven wiring
&lt;/h3&gt;

&lt;p&gt;Instead of building clients in code, bind them from configuration under the &lt;code&gt;solon.ai.mcp.client.&amp;lt;name&amp;gt;&lt;/code&gt; prefix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Bean&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;McpClientProvider&lt;/span&gt; &lt;span class="nf"&gt;clientWrapper&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nd"&gt;@Inject&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${solon.ai.mcp.client.demo}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;McpClientProvider&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is also &lt;code&gt;McpClientProviders.fromMcpServers(uri)&lt;/code&gt; for loading a whole &lt;code&gt;mcpServers&lt;/code&gt;-style map at once — useful when your tool landscape lives in config rather than Java.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas I hit while reading the source
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The Javadoc lies a little.&lt;/strong&gt; The class-level example in &lt;code&gt;McpClientProvider&lt;/code&gt; shows &lt;code&gt;.apiUrl(...)&lt;/code&gt; and &lt;code&gt;.defaultToolsAdd(...)&lt;/code&gt;; the actual builder method is &lt;code&gt;.url(...)&lt;/code&gt;, and the demo code uses &lt;code&gt;defaultToolAdd&lt;/code&gt;. Trust the code, not the comment — I have reported the drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-parameters&lt;/code&gt; matters.&lt;/strong&gt; Without the compiler flag, parameter names vanish from bytecode, and &lt;code&gt;@Param&lt;/code&gt; needs an explicit &lt;code&gt;name&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heartbeat defaults are asymmetric.&lt;/strong&gt; Server: 30s on. Client: off. Do not assume both ends keep-alive the same way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stateless is a mode, not a transport.&lt;/strong&gt; &lt;code&gt;STREAMABLE_STATELESS&lt;/code&gt; uses the same HTTP transport; the difference is in session handling on the server host.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  When does this matter?
&lt;/h2&gt;

&lt;p&gt;The annotation server shines when you have an existing Solon service full of business methods that AI agents suddenly need to call. The client shines when you want to compose capabilities across process boundaries — a weather server here, a database MCP there, all flowing into one &lt;code&gt;ChatModel&lt;/code&gt; with retries and caching you did not write.&lt;/p&gt;

&lt;p&gt;Together with the streaming events and semantic splitting covered earlier, that completes a picture worth remembering: Solon AI treats MCP not as a bolt-on integration but as another expression of the same builder-and-provider abstractions the rest of the framework runs on.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Solon AI repository and docs: &lt;a href="https://solon.noear.org/article/learn-solon-ai" rel="noopener noreferrer"&gt;https://solon.noear.org/article/learn-solon-ai&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Model Context Protocol specification: &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;https://modelcontextprotocol.io&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Earlier in this series: &lt;a href="https://dev.to/solonjava/beyond-token-streaming-solon-ai-41s-semantic-chat-events-5g8o"&gt;Semantic Chat Events&lt;/a&gt;, &lt;a href="https://dev.to/solonjava/chunk-by-meaning-not-just-size-a-deep-dive-into-solon-ais-semanticsplitter-49c3"&gt;SemanticSplitter&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Verified against the Solon AI 4.1.x source tree; class and method names reflect that version.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>java</category>
      <category>mcp</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Chunk by Meaning, Not Just Size: A Deep Dive into Solon AI's SemanticSplitter</title>
      <dc:creator>Solon Framework</dc:creator>
      <pubDate>Mon, 07 Sep 2026 06:07:19 +0000</pubDate>
      <link>https://dev.to/solonjava/chunk-by-meaning-not-just-size-a-deep-dive-into-solon-ais-semanticsplitter-49c3</link>
      <guid>https://dev.to/solonjava/chunk-by-meaning-not-just-size-a-deep-dive-into-solon-ais-semanticsplitter-49c3</guid>
      <description>&lt;h1&gt;
  
  
  Chunk by Meaning, Not Just Size: A Deep Dive into Solon AI’s SemanticSplitter
&lt;/h1&gt;

&lt;p&gt;RAG quality is often discussed as if the only question were which vector database to choose. In practice, the shape of the text entering that database matters just as much. If one chunk mixes a refund rule, a shipping exception, and an account-security note, a strong embedding model cannot fully repair the damaged boundary.&lt;/p&gt;

&lt;p&gt;Solon AI’s &lt;code&gt;SemanticSplitter&lt;/code&gt; offers a different trade-off from regex- and token-based splitting: it uses an embedding model to look for likely topic boundaries before applying a token-size fallback.&lt;/p&gt;

&lt;p&gt;This article examines what the implementation actually does in Solon AI &lt;code&gt;v4.1.0&lt;/code&gt;, where it fits in the RAG pipeline, and which assumptions an application should still validate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The splitter is an ingestion-stage component
&lt;/h2&gt;

&lt;p&gt;Solon’s RAG documentation separates the main stages clearly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DocumentLoader -&amp;gt; DocumentSplitter -&amp;gt; RepositoryStorable.save(...)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DocumentLoader&lt;/code&gt; turns a file or other source into &lt;code&gt;Document&lt;/code&gt; objects. A &lt;code&gt;DocumentSplitter&lt;/code&gt; can then turn large documents into smaller retrieval units. The resulting documents are finally stored in a &lt;code&gt;Repository&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SemanticSplitter&lt;/code&gt; belongs to the middle stage. It does not query a vector database and it does not decide which documents to retrieve at runtime. Its job is to decide where the boundaries of the documents should be before indexing.&lt;/p&gt;

&lt;p&gt;The class is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;noear&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;solon&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ai&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;rag&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;splitter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SemanticSplitter&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and it implements the same &lt;code&gt;DocumentSplitter&lt;/code&gt; abstraction used by other Solon AI splitters.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the default constructor configures
&lt;/h2&gt;

&lt;p&gt;The simplest construction is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;SemanticSplitter&lt;/span&gt; &lt;span class="n"&gt;splitter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SemanticSplitter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embeddingModel&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The source defines these defaults:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;similarityThreshold&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A lower similarity marks a candidate boundary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;maxChunkTokenSize&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;512&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Target maximum size for a semantic group&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;similarityWindow&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Number of preceding sentences used as context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;minSentencesPerChunk&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Minimum distance between accepted boundaries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delimiters&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ALL_COMMON_DELIM&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Common English, Chinese, and newline delimiters&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are defaults, not universal production settings. Similarity distributions vary between embedding models, languages, and corpora. A threshold that is useful for one model can be too aggressive or too conservative for another.&lt;/p&gt;

&lt;h2&gt;
  
  
  The algorithm is a sliding-window comparison
&lt;/h2&gt;

&lt;p&gt;The implementation does not embed the entire document once and then run a generic clustering algorithm. Its flow is more specific:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;raw text
  -&amp;gt; sentence-like segments
  -&amp;gt; sliding context windows
  -&amp;gt; embeddings for windows and following sentences
  -&amp;gt; cosine similarities
  -&amp;gt; threshold-based boundaries
  -&amp;gt; token-size fallback
  -&amp;gt; new Documents with inherited metadata
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  1. Sentence-like segmentation
&lt;/h3&gt;

&lt;p&gt;The splitter scans the text using the configured delimiters. The delimiter remains attached to the preceding segment. The built-in common set includes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;SemanticSplitter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ALL_COMMON_DELIM&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;which covers &lt;code&gt;.&lt;/code&gt;, &lt;code&gt;!&lt;/code&gt;, &lt;code&gt;?&lt;/code&gt;, Chinese &lt;code&gt;。&lt;/code&gt;, &lt;code&gt;！&lt;/code&gt;, &lt;code&gt;？&lt;/code&gt;, &lt;code&gt;；&lt;/code&gt;, and newlines.&lt;/p&gt;

&lt;p&gt;This is deliberately simpler than a full natural-language sentence parser. If a document uses unusual punctuation or has long sections without the configured delimiters, the resulting segments will reflect that.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Build a context window
&lt;/h3&gt;

&lt;p&gt;For each position, the splitter concatenates the next &lt;code&gt;similarityWindow&lt;/code&gt; sentences into a window. With the default window of three, the comparison looks conceptually like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[sentence 0 + sentence 1 + sentence 2]  vs  [sentence 3]
[sentence 1 + sentence 2 + sentence 3]  vs  [sentence 4]
[sentence 2 + sentence 3 + sentence 4]  vs  [sentence 5]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each side is embedded. The cosine similarity between the window and the following sentence becomes the signal for that position.&lt;/p&gt;

&lt;p&gt;A low score suggests that the next sentence may represent a topic transition. It is a candidate boundary, not a semantic truth guarantee.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Apply the threshold
&lt;/h3&gt;

&lt;p&gt;The source uses the direction below:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;similarities&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;similarityThreshold&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// candidate split&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This direction matters. Holding the text, model, and other parameters constant, increasing the threshold makes it easier for a position to fall below the threshold, so it will usually create more candidate boundaries. Lowering the threshold usually keeps larger groups together.&lt;/p&gt;

&lt;p&gt;The result is not a simple linear knob. The final chunks also depend on the embedding model, the window size, the minimum-sentence rule, and the token fallback.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Enforce a token-size fallback
&lt;/h3&gt;

&lt;p&gt;After semantic groups are created, the splitter counts tokens using its configured jtokkit encoding. If a group is over &lt;code&gt;maxChunkTokenSize&lt;/code&gt;, it allocates sentences into smaller groups until the group would exceed the limit.&lt;/p&gt;

&lt;p&gt;That makes the token limit a second-stage guardrail, not the primary boundary detector.&lt;/p&gt;

&lt;p&gt;There is an important edge case: the fallback only moves whole sentences. If one individual sentence is already larger than the configured maximum, it can still appear as an oversized output chunk. Also, very short documents with no more than &lt;code&gt;similarityWindow&lt;/code&gt; segments are returned as one document before the normal embedding and token fallback path runs.&lt;/p&gt;

&lt;p&gt;So the safe statement is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;SemanticSplitter&lt;/code&gt; applies a token-size fallback to oversized semantic groups, but applications should still validate final chunk sizes for short documents and individually oversized sentences.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Embedding cost is part of the design
&lt;/h2&gt;

&lt;p&gt;Regex splitting and token splitting can be performed locally. Semantic splitting calls the configured &lt;code&gt;EmbeddingModel&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For a document with &lt;code&gt;N&lt;/code&gt; segments and a window size of &lt;code&gt;W&lt;/code&gt;, the normal path builds approximately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;N - W window texts
N - W following-sentence texts
2 * (N - W) embedding inputs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The inputs are sent in batches according to &lt;code&gt;embeddingModel.batchSize()&lt;/code&gt;. The current implementation also requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dimensions&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That detail deserves operational attention. The selected embedding provider must support the requested dimension behavior, and the application should measure whether the chosen dimension preserves acceptable boundary quality for its corpus. Do not assume that every provider interprets dimensionality options identically.&lt;/p&gt;

&lt;p&gt;The practical cost model is therefore different:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Splitter&lt;/th&gt;
&lt;th&gt;Boundary signal&lt;/th&gt;
&lt;th&gt;Typical cost&lt;/th&gt;
&lt;th&gt;Main risk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RegexTextSplitter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Document structure&lt;/td&gt;
&lt;td&gt;Local CPU&lt;/td&gt;
&lt;td&gt;Structure may be inconsistent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TokenSizeTextSplitter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Token count&lt;/td&gt;
&lt;td&gt;Local CPU&lt;/td&gt;
&lt;td&gt;A chunk can cross topic boundaries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SemanticSplitter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Embedding similarity&lt;/td&gt;
&lt;td&gt;Embedding calls plus CPU&lt;/td&gt;
&lt;td&gt;Cost and thresholds need validation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is not a ranking in which the semantic splitter always wins. It is a choice between different failure modes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metadata is preserved, but chunk identity is not invented
&lt;/h2&gt;

&lt;p&gt;When the splitter creates a new &lt;code&gt;Document&lt;/code&gt;, it passes the original metadata through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Document&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is useful for filters such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;source file;&lt;/li&gt;
&lt;li&gt;department;&lt;/li&gt;
&lt;li&gt;tenant;&lt;/li&gt;
&lt;li&gt;document version;&lt;/li&gt;
&lt;li&gt;access scope.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;However, the splitter does not automatically add a chunk number, parent-document ID, page number, character offsets, or neighboring-chunk links. If those fields are needed for citation, deletion, re-indexing, or audit trails, add them in the application’s ingestion layer rather than assuming they are framework-generated.&lt;/p&gt;

&lt;p&gt;A production ingestion pipeline might therefore enrich the metadata after splitting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Document&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;splitter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;split&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loadedDocuments&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Add application-owned tracking fields here:&lt;/span&gt;
    &lt;span class="c1"&gt;// chunkIndex, sourceId, pageNumber, version, and so on.&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact metadata API depends on the application’s ownership model. The important design point is that chunk tracking is an application responsibility unless the loader already supplies the required fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to evaluate it instead of guessing
&lt;/h2&gt;

&lt;p&gt;Semantic chunking should be evaluated with the same discipline as a retrieval model. A useful experiment can use one mixed-topic corpus containing, for example:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;refund rules;&lt;/li&gt;
&lt;li&gt;shipping compensation;&lt;/li&gt;
&lt;li&gt;account-security requirements;&lt;/li&gt;
&lt;li&gt;similar vocabulary across all three sections.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Compare at least these measurements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;number of produced chunks;&lt;/li&gt;
&lt;li&gt;average and P95 token count;&lt;/li&gt;
&lt;li&gt;number of chunks crossing a known section boundary;&lt;/li&gt;
&lt;li&gt;embedding request count;&lt;/li&gt;
&lt;li&gt;ingestion latency;&lt;/li&gt;
&lt;li&gt;Recall@K on a fixed question set;&lt;/li&gt;
&lt;li&gt;retrieved-context token count;&lt;/li&gt;
&lt;li&gt;whether the final answer cites the correct section.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A small parameter sweep is also more informative than copying defaults blindly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;similarityThreshold: 0.35 / 0.50 / 0.65
similarityWindow:    1 / 3 / 5
maxChunkTokenSize:   256 / 512 / 1024
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Report results with the corpus, embedding model, parameter values, and measurement method. Without those details, a claim such as “semantic splitting improves accuracy” is too broad to be reliable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Agent RAG fits
&lt;/h2&gt;

&lt;p&gt;Solon AI has separate concepts for ingestion and runtime retrieval. A useful mental model is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SemanticSplitter
      -&amp;gt; better-shaped Documents
      -&amp;gt; Repository
      -&amp;gt; RepositoryTool
      -&amp;gt; ReActAgent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These components answer different questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SemanticSplitter&lt;/code&gt;: where should the knowledge be divided?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Repository&lt;/code&gt;: where are the indexed documents stored and searched?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RepositoryTool&lt;/code&gt;: how can retrieval be exposed as a tool?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ReActAgent&lt;/code&gt;: when should the agent search, and whether it should search again?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Improving chunk boundaries can help the retrieval layer, but it does not turn the splitter into an agent. Conversely, an agent cannot reliably compensate for every bad ingestion boundary. Treat the ingestion strategy and runtime strategy as separate, measurable layers.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical decision guide
&lt;/h2&gt;

&lt;p&gt;Use a structure-based splitter when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;headings and paragraphs are reliable;&lt;/li&gt;
&lt;li&gt;indexing cost must be minimal;&lt;/li&gt;
&lt;li&gt;deterministic boundaries are important;&lt;/li&gt;
&lt;li&gt;you need straightforward debugging.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use a token splitter when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;model input limits are the dominant constraint;&lt;/li&gt;
&lt;li&gt;the corpus has weak structure;&lt;/li&gt;
&lt;li&gt;you want predictable chunk sizes;&lt;/li&gt;
&lt;li&gt;the retrieval quality trade-off is acceptable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Evaluate &lt;code&gt;SemanticSplitter&lt;/code&gt; when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the corpus contains frequent topic transitions inside long sections;&lt;/li&gt;
&lt;li&gt;structural delimiters are not enough;&lt;/li&gt;
&lt;li&gt;you can afford embedding work during ingestion;&lt;/li&gt;
&lt;li&gt;you have a representative retrieval benchmark.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A hybrid pipeline can also be reasonable, but its order and benefit should be tested on the target corpus rather than declared as a universal best practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thoughts
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SemanticSplitter&lt;/code&gt; is interesting precisely because it is not magic. It is a concrete algorithm with visible costs and boundaries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;sentence-like segmentation first;&lt;/li&gt;
&lt;li&gt;sliding-window embeddings;&lt;/li&gt;
&lt;li&gt;cosine similarity as a boundary signal;&lt;/li&gt;
&lt;li&gt;threshold-based grouping;&lt;/li&gt;
&lt;li&gt;token-size fallback;&lt;/li&gt;
&lt;li&gt;metadata inheritance;&lt;/li&gt;
&lt;li&gt;no automatic chunk identity model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That makes it possible to reason about, benchmark, and adapt. In a Solon AI RAG pipeline, the best splitter is not the one with the most sophisticated name. It is the one whose boundary behavior, embedding cost, and retrieval results are understood on your own documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/opensolon/solon-ai/blob/v4.1.0/solon-ai-core/src/main/java/org/noear/solon/ai/rag/splitter/SemanticSplitter.java" rel="noopener noreferrer"&gt;SemanticSplitter source in Solon AI v4.1.0&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://raw.githubusercontent.com/opensolon/solon-ai/v4.1.0/solon-ai-core/src/main/java/org/noear/solon/ai/rag/splitter/SemanticSplitter.java" rel="noopener noreferrer"&gt;Raw SemanticSplitter source&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://solon.noear.org/article/935" rel="noopener noreferrer"&gt;Solon AI: Document loading and splitting&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://solon.noear.org/article/1075" rel="noopener noreferrer"&gt;Solon AI: Repository&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>java</category>
      <category>opensource</category>
      <category>rag</category>
    </item>
    <item>
      <title>Beyond Token Streaming: Solon AI 4.1's Semantic Chat Events</title>
      <dc:creator>Solon Framework</dc:creator>
      <pubDate>Mon, 07 Sep 2026 04:30:29 +0000</pubDate>
      <link>https://dev.to/solonjava/beyond-token-streaming-solon-ai-41s-semantic-chat-events-5g8o</link>
      <guid>https://dev.to/solonjava/beyond-token-streaming-solon-ai-41s-semantic-chat-events-5g8o</guid>
      <description>&lt;p&gt;Streaming an LLM response looks simple until the response contains more than text.&lt;/p&gt;

&lt;p&gt;Depending on the provider, a modern stream may include reasoning fragments, tool-call arguments, citations, media updates, safety decisions, usage snapshots, status messages, and errors over the same connection. Treating every frame as “another partial response” pushes provider-specific parsing into the UI and makes it difficult to distinguish a transient fragment from the final answer.&lt;/p&gt;

&lt;p&gt;Solon AI 4.1 addresses that problem with a semantic event stream:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before 4.1:       Flux&amp;lt;ChatResponse&amp;gt;
Starting with 4.1: Flux&amp;lt;ChatEvent&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The change is more than a generic type replacement. &lt;code&gt;ChatEvent&lt;/code&gt; represents what is happening while a response is in flight. &lt;code&gt;ChatResponse&lt;/code&gt; represents an aggregated result. The distinction gives application code a vocabulary for text, reasoning, tools, lifecycle, usage, and failure without requiring it to understand every provider's raw SSE dialect.&lt;/p&gt;

&lt;p&gt;This article builds on the official &lt;a href="https://solon.noear.org/article/1495" rel="noopener noreferrer"&gt;streaming event guide&lt;/a&gt; and checks the behavior against the Solon AI 4.1 source and core tests. One important qualification: the event API is marked &lt;code&gt;@Preview("4.1")&lt;/code&gt; in the current source, so treat it as a preview contract that may evolve in future releases.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;call()&lt;/code&gt; and &lt;code&gt;stream()&lt;/code&gt; now answer different questions
&lt;/h2&gt;

&lt;p&gt;The synchronous API still asks for the completed answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;ChatResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Explain semantic streaming"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getText&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The streaming API asks to observe the response lifecycle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ChatEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chatModel&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Explain semantic streaming"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That difference prevents an intermediate frame from pretending to be a complete response.&lt;/p&gt;

&lt;p&gt;For a text-only projection, select the exact semantic event you need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;is&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatEventType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TEXT_DELTA&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasText&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;ChatEvent:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getText&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details are intentional:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;isDelta()&lt;/code&gt; is too broad for a typewriter projection. It also covers reasoning, tool arguments, media partials, and refusal deltas.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;getText()&lt;/code&gt; is nullable. &lt;code&gt;hasText()&lt;/code&gt; keeps a Reactor &lt;code&gt;map&lt;/code&gt; from receiving a null result.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the first practical rule of the new model: &lt;strong&gt;project by meaning, not by transport shape&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nine groups provide the current routing layer
&lt;/h2&gt;

&lt;p&gt;The current source defines 31 event types organized into nine groups:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;What it represents&lt;/th&gt;
&lt;th&gt;Representative events&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LIFECYCLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Whole-response state&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;RESPONSE_START&lt;/code&gt;, &lt;code&gt;STATUS&lt;/code&gt;, &lt;code&gt;HEARTBEAT&lt;/code&gt;, &lt;code&gt;RESPONSE_END&lt;/code&gt;, &lt;code&gt;ABORT&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;STEP&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One model round&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;STEP_START&lt;/code&gt;, &lt;code&gt;STEP_END&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TEXT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;User-visible answer text&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;TEXT_START&lt;/code&gt;, &lt;code&gt;TEXT_DELTA&lt;/code&gt;, &lt;code&gt;TEXT_END&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;THINKING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reasoning-related output&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;THINKING_START&lt;/code&gt;, &lt;code&gt;THINKING_DELTA&lt;/code&gt;, &lt;code&gt;THINKING_END&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TOOL_CALL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Client-executed tools&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;TOOL_CALL_START&lt;/code&gt;, &lt;code&gt;TOOL_CALL_ARGS_DELTA&lt;/code&gt;, &lt;code&gt;TOOL_CALL_END&lt;/code&gt;, &lt;code&gt;TOOL_RESULT&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SERVER_TOOL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Provider-side tools&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SERVER_TOOL_START&lt;/code&gt;, &lt;code&gt;SERVER_TOOL_ARGS_DELTA&lt;/code&gt;, &lt;code&gt;SERVER_TOOL_RESULT&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MEDIA&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Citations and generated media&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CITATION&lt;/code&gt;, &lt;code&gt;MEDIA_PARTIAL&lt;/code&gt;, &lt;code&gt;MEDIA_DONE&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SAFETY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Refusals and filtering&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;REFUSAL_DELTA&lt;/code&gt;, &lt;code&gt;CONTENT_FILTER&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;META&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Usage, errors, raw and custom data&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;USAGE&lt;/code&gt;, &lt;code&gt;ERROR&lt;/code&gt;, &lt;code&gt;RAW&lt;/code&gt;, &lt;code&gt;CUSTOM&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These groups are useful when building a generic router. Specific event types can evolve while the application keeps stable top-level destinations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getGroup&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nl"&gt;TEXT:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;is&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatEventType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TEXT_DELTA&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasText&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;ui&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;appendAnswer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getText&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nl"&gt;THINKING:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;is&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatEventType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;THINKING_DELTA&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasText&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;ui&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;appendReasoning&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getText&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nl"&gt;TOOL_CALL:&lt;/span&gt;
            &lt;span class="n"&gt;toolPanel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;accept&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nl"&gt;SERVER_TOOL:&lt;/span&gt;
            &lt;span class="n"&gt;serverToolPanel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;accept&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nl"&gt;MEDIA:&lt;/span&gt;
            &lt;span class="n"&gt;mediaPanel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;accept&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nl"&gt;SAFETY:&lt;/span&gt;
            &lt;span class="n"&gt;safetyPanel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;accept&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nl"&gt;STEP:&lt;/span&gt;
            &lt;span class="n"&gt;timeline&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;accept&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nl"&gt;LIFECYCLE:&lt;/span&gt;
            &lt;span class="n"&gt;lifecycle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;accept&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nl"&gt;META:&lt;/span&gt;
            &lt;span class="n"&gt;diagnostics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;accept&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Unhandled event type: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getType&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping a &lt;code&gt;default&lt;/code&gt; branch is still sensible for preview APIs and future versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  An event-phase &lt;code&gt;END&lt;/code&gt; is not necessarily the end of the stream
&lt;/h2&gt;

&lt;p&gt;Events also have phases such as &lt;code&gt;START&lt;/code&gt;, &lt;code&gt;DELTA&lt;/code&gt;, &lt;code&gt;END&lt;/code&gt;, and &lt;code&gt;NONE&lt;/code&gt;. The word &lt;code&gt;END&lt;/code&gt; is local to an event scope.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;TEXT_END&lt;/code&gt; closes a text block;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;THINKING_END&lt;/code&gt; closes a reasoning block;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TOOL_CALL_END&lt;/code&gt; closes one tool call;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;STEP_END&lt;/code&gt; closes one model round;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RESPONSE_END&lt;/code&gt; closes a successful response lifecycle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use &lt;code&gt;event.isTerminal()&lt;/code&gt; when you mean a whole-stream semantic terminal. In the current enum, it is true for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RESPONSE_END
ABORT
ERROR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not replace that check with &lt;code&gt;event.getPhase() == END&lt;/code&gt;. &lt;code&gt;ERROR&lt;/code&gt; is terminal but has phase &lt;code&gt;NONE&lt;/code&gt;, while several local boundary events have phase &lt;code&gt;END&lt;/code&gt; without terminating the response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recover the final response from &lt;code&gt;RESPONSE_END&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;If a caller needs the final aggregate while using the streaming path, it should select &lt;code&gt;RESPONSE_END&lt;/code&gt; first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;ChatResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;is&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatEventType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;RESPONSE_END&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;ChatEvent:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getResponse&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;blockFirst&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The asynchronous form can expose the first matching terminal response as a &lt;code&gt;Mono&amp;lt;ChatResponse&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Mono&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ChatResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;is&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatEventType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;RESPONSE_END&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;ChatEvent:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getResponse&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calling &lt;code&gt;blockFirst()&lt;/code&gt; on the unfiltered stream is a classic migration mistake. The first event is normally &lt;code&gt;RESPONSE_START&lt;/code&gt;, not the final answer.&lt;/p&gt;

&lt;p&gt;The caller does not need to concatenate every &lt;code&gt;TEXT_DELTA&lt;/code&gt; to reconstruct the official final response. Solon AI accumulates the response internally and attaches the aggregate to the terminal event. Local concatenation can still be useful for immediate rendering, but it should be treated as a UI projection rather than the source of truth for tool calls, usage, and the complete assistant message.&lt;/p&gt;

&lt;h2&gt;
  
  
  The normalizer creates consistent stateful event boundaries
&lt;/h2&gt;

&lt;p&gt;Provider stream formats differ in how consistently they expose boundaries. A provider may send a text delta without an explicit text-start frame. Tool-call fragments may omit a stable identifier after the first frame. Reasoning and answer text may alternate. A stream can terminate while a content block is still open.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ChatEventNormalizer&lt;/code&gt; sits between parsed provider semantics and the subscriber. Its job is to make event boundaries usable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TEXT_START -&amp;gt; TEXT_DELTA* -&amp;gt; TEXT_END
THINKING_START -&amp;gt; THINKING_DELTA* -&amp;gt; THINKING_END
TOOL_CALL_START -&amp;gt; TOOL_CALL_ARGS_DELTA* -&amp;gt; TOOL_CALL_END
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For text and thinking blocks, the normalizer uses stricter tracking:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a bare delta can cause a matching start to be synthesized;&lt;/li&gt;
&lt;li&gt;duplicate starts for the same block are dropped;&lt;/li&gt;
&lt;li&gt;an unmatched end is dropped;&lt;/li&gt;
&lt;li&gt;switching between thinking and text closes the previously open block;&lt;/li&gt;
&lt;li&gt;completion closes remaining open blocks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tool calls use a deliberately looser policy. An arguments delta can synthesize a start when no call is open, and an unmatched end is preserved. This is not a malformed-stream validator. It is a compatibility layer for providers whose later argument fragments do not repeat a complete tool-call ID.&lt;/p&gt;

&lt;p&gt;That difference is important when writing application code. The normalizer provides compatibility-oriented boundary completion, not strict tool-call identity validation. Use normalized events for rendering and correlation, but read the complete &lt;code&gt;ToolCall&lt;/code&gt; objects from a completed step or final response rather than assuming that one argument delta contains valid JSON.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool execution introduces steps
&lt;/h2&gt;

&lt;p&gt;An automatic tool workflow is not a single model round. It commonly looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RESPONSE_START
  STEP_START (0)
    TOOL_CALL_START
    TOOL_CALL_ARGS_DELTA ...
    TOOL_CALL_END
    TOOL_RESULT
  STEP_END (0)
  STEP_START (1)
    TEXT_START
    TEXT_DELTA ...
    TEXT_END
  STEP_END (1)
RESPONSE_END
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response lifecycle remains one unit, while each provider request becomes a step. The current implementation starts step numbering at zero and increments it for recursive model calls.&lt;/p&gt;

&lt;p&gt;A normally completed &lt;code&gt;STEP_END&lt;/code&gt; event carries the terminal snapshot and usage for that step. &lt;code&gt;RESPONSE_END&lt;/code&gt; carries the whole successful response aggregate and cross-step usage total. This lets an observability system answer two different questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What did this model round produce and cost?&lt;/li&gt;
&lt;li&gt;What did the complete tool-assisted response produce and cost?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tool argument fragments may be interleaved when multiple calls are in flight. Correlate them by &lt;code&gt;toolCallId&lt;/code&gt; when one is available; do not build a parser that assumes all fragments for one call arrive as a single contiguous JSON document.&lt;/p&gt;

&lt;h2&gt;
  
  
  Usage is a two-level aggregation problem
&lt;/h2&gt;

&lt;p&gt;Usage data is easy to overcount.&lt;/p&gt;

&lt;p&gt;Within one provider step, usage frames are commonly cumulative snapshots. Adding every frame would count the same tokens repeatedly. Across steps, however, each step is a separate model request, so completed-step totals need to be added.&lt;/p&gt;

&lt;p&gt;Solon AI's stream session reflects that distinction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;within a step: keep/merge the provider snapshot
across steps:  add completed-step usage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Consequently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;STEP_END.getUsage()&lt;/code&gt; describes the current completed step;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RESPONSE_END.getUsage()&lt;/code&gt; describes the accumulated successful response;&lt;/li&gt;
&lt;li&gt;a standalone &lt;code&gt;USAGE&lt;/code&gt; event is an observation, not an instruction to blindly add every value it contains.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The source also preserves provider usage metadata across steps. Numeric fields such as token and server-tool counts are accumulated, while label-like values use different merge behavior. The larger lesson is portable: billing telemetry needs a scope model, not just a counter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Errors, aborts, and cancellation are different
&lt;/h2&gt;

&lt;p&gt;A production subscriber must distinguish three mechanisms.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;ERROR&lt;/code&gt; and Reactor &lt;code&gt;onError&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;For failures that enter the main reactive error path, Solon AI attempts to expose an &lt;code&gt;ERROR&lt;/code&gt; event before terminating the Reactor stream with an error. The two channels serve different purposes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the event channel can carry semantic context such as a previously completed response snapshot or accumulated usage;&lt;/li&gt;
&lt;li&gt;Reactor &lt;code&gt;onError&lt;/code&gt; drives retry, fallback, timeout, and recovery operators.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They describe one failure, not two failures. Also, do not assume that &lt;code&gt;ERROR.getResponse()&lt;/code&gt; contains the partially streamed text from the currently failing step. The aggregate may contain only previously completed steps, or it may be null when the first step fails early.&lt;/p&gt;

&lt;p&gt;A custom event filter can suppress a &lt;code&gt;META&lt;/code&gt; event such as &lt;code&gt;ERROR&lt;/code&gt;, while Reactor &lt;code&gt;onError&lt;/code&gt; still arrives. Keep an error consumer even if the UI also handles error events:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;chatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;subscribe&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;recoverOrReport&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
        &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;markTransportComplete&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;ABORT&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ABORT&lt;/code&gt; is an upstream semantic event. It is not the same thing as the subscriber cancelling its subscription. Application code can route it as a lifecycle signal, but should not infer Reactor cancellation from it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reactor cancellation
&lt;/h3&gt;

&lt;p&gt;Operators such as &lt;code&gt;take(...)&lt;/code&gt; may cancel the subscription. Once cancellation occurs, the downstream cannot expect extra events. In particular, it should not wait for synthetic &lt;code&gt;TEXT_END&lt;/code&gt;, &lt;code&gt;STEP_END&lt;/code&gt;, &lt;code&gt;ABORT&lt;/code&gt;, or &lt;code&gt;RESPONSE_END&lt;/code&gt; events after it has cancelled.&lt;/p&gt;

&lt;p&gt;Cleanup belongs in Reactor lifecycle hooks such as &lt;code&gt;doFinally&lt;/code&gt;, not in a handler that assumes every path ends with &lt;code&gt;RESPONSE_END&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;chatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;doFinally&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signal&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;releaseUiResources&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;subscribe&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;recoverOrReport&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Event filtering is delivery policy, not aggregation policy
&lt;/h2&gt;

&lt;p&gt;The default filter rejects high-volume &lt;code&gt;HEARTBEAT&lt;/code&gt; and unmodeled &lt;code&gt;RAW&lt;/code&gt; events. Diagnostic or gateway code can request more:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;chatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eventFilter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatEventFilter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;all&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or it can extend the default policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;ChatEventFilter&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ChatEventFilter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DEFAULT&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;or&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;ChatEventFilter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatEventType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;RAW&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="n"&gt;chatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eventFilter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is a subtle source-level detail worth knowing. Runtime filtering is guarded so lifecycle and step groups survive a non-null custom filter. Therefore, this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eventFilter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatEventFilter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatEventType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TEXT_DELTA&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;does &lt;strong&gt;not&lt;/strong&gt; mean the subscriber will receive only text deltas. Lifecycle and step events remain available for aggregation and boundaries. In the current preview implementation, the interaction between the default filter, guarded custom filters, and &lt;code&gt;HEARTBEAT&lt;/code&gt; is nuanced because heartbeat itself belongs to the lifecycle group.&lt;/p&gt;

&lt;p&gt;The robust approach is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;use &lt;code&gt;eventFilter&lt;/code&gt; to control broad delivery volume;&lt;/li&gt;
&lt;li&gt;still use an exact downstream &lt;code&gt;filter&lt;/code&gt; when projecting one semantic channel;&lt;/li&gt;
&lt;li&gt;use &lt;code&gt;ChatEventFilter.all()&lt;/code&gt; only when raw protocol visibility is genuinely needed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Filtering happens after Solon AI's internal normalization and aggregation. Hiding an event from a subscriber does not undo the internal final response.&lt;/p&gt;

&lt;h2&gt;
  
  
  A provider dialect translates frames; it should not duplicate semantics
&lt;/h2&gt;

&lt;p&gt;A custom provider integration implements the streaming parser entry point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;parseResponseJson&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatStreamContext&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;respJson&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Conceptually, there are two output paths:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Put primary content such as answer text, reasoning, and client tool calls into the accumulator so the core can generate standard events and aggregates.&lt;/li&gt;
&lt;li&gt;Emit semantic events directly for provider capabilities such as citations, status, server-side tools, or specialized media.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The following is illustrative pseudocode; &lt;code&gt;readProviderResponseId&lt;/code&gt;, &lt;code&gt;readTextDelta&lt;/code&gt;, &lt;code&gt;readCitation&lt;/code&gt;, and &lt;code&gt;buildCitation&lt;/code&gt; are provider-specific helpers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Override&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;parseResponseJson&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatStreamContext&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setProviderResponseId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;readProviderResponseId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;readTextDelta&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAccumulator&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;addContentItem&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;AssistantMessage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;citationUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;readCitation&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;citationUrl&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;emit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatEventType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CITATION&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;block&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buildCitation&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;citationUrl&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not send the same semantic payload through both paths. If text is added to the accumulator and also emitted as a text delta, consumers can see duplicate deltas and the aggregate can be corrupted.&lt;/p&gt;

&lt;p&gt;The unified layer is valuable precisely because provider frames and application events are not one-to-one. A single frame may update usage and text; one semantic tool call may span many frames. The dialect should translate provider protocol, while the core owns the cross-provider event contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical migration checklist
&lt;/h2&gt;

&lt;p&gt;When moving pre-4.1 streaming code to the preview event model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Replace &lt;code&gt;Flux&amp;lt;ChatResponse&amp;gt;&lt;/code&gt; declarations with &lt;code&gt;Flux&amp;lt;ChatEvent&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Render answer text only from &lt;code&gt;TEXT_DELTA&lt;/code&gt; plus &lt;code&gt;hasText()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Route reasoning separately from answer text.&lt;/li&gt;
&lt;li&gt;Do not use &lt;code&gt;isDelta()&lt;/code&gt; as a synonym for user-visible text.&lt;/li&gt;
&lt;li&gt;Select &lt;code&gt;RESPONSE_END&lt;/code&gt; before calling &lt;code&gt;blockFirst()&lt;/code&gt; or &lt;code&gt;next()&lt;/code&gt; for the final response.&lt;/li&gt;
&lt;li&gt;Read completed tool calls from &lt;code&gt;STEP_END&lt;/code&gt; or &lt;code&gt;RESPONSE_END&lt;/code&gt;, not from one arguments fragment.&lt;/li&gt;
&lt;li&gt;Treat &lt;code&gt;STEP_END&lt;/code&gt; usage and &lt;code&gt;RESPONSE_END&lt;/code&gt; usage as different scopes.&lt;/li&gt;
&lt;li&gt;Keep Reactor error handling even when consuming &lt;code&gt;ERROR&lt;/code&gt; events.&lt;/li&gt;
&lt;li&gt;Treat cancellation as a transport/control action, not as &lt;code&gt;ABORT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Revisit custom dialects for the &lt;code&gt;parseResponseJson(ChatStreamContext, String)&lt;/code&gt; contract.&lt;/li&gt;
&lt;li&gt;Re-check the exact 4.1 preview API before upgrading future versions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the tests establish
&lt;/h2&gt;

&lt;p&gt;The core source contains focused tests for the event model. I ran these three deterministic suites against the reviewed checkout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ChatEventNormalizerTest       20 tests
ChatEventFilterTest            6 tests
ChatStreamSessionUsageTest     9 tests
---------------------------------------
Total                         35 tests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tests run: 35, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They provide a useful regression net for boundary normalization, filter composition, and cross-step usage aggregation. They do not prove every arbitrary event sequence, every provider's behavior, deep immutability of every payload, or every cancellation race. Provider capabilities also differ: an application must not expect every dialect to emit reasoning, citations, media, safety, server-tool, or usage events.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architectural payoff
&lt;/h2&gt;

&lt;p&gt;The most useful part of Solon AI 4.1's streaming redesign is not the number of event types. It is the separation of responsibilities:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;provider SSE / JSON frames
          ↓
provider dialect parsing
          ↓
semantic accumulation and event emission
          ↓
boundary normalization
          ↓
application routing and protocol adapters
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A UI can focus on rendering. An agent can focus on tool steps. An observability layer can focus on usage and failure. A gateway can opt into raw events without forcing ordinary consumers to pay that cost.&lt;/p&gt;

&lt;p&gt;Token streaming answers, “What bytes arrived next?” Semantic streaming answers, “What happened next?” For applications that combine reasoning, tools, citations, safety, and multiple provider calls, the semantic model is generally easier to extend.&lt;/p&gt;

&lt;p&gt;Further reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://solon.noear.org/article/1495" rel="noopener noreferrer"&gt;Official Solon AI streaming event guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/opensolon/solon-ai" rel="noopener noreferrer"&gt;Solon AI source repository&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>java</category>
      <category>ai</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
