davsclaus commented on code in PR #1800: URL: https://github.com/apache/camel-website/pull/1800#discussion_r4167847352
########## content/blog/2026/10/semantic-agent-routing/index.md: ########## @@ -0,0 +1,340 @@ +--- +title: "One request, several agents: semantic routing with Apache Camel and Jev" +date: 2026-10-02 +draft: false +authors: [ luigidemasi ] +categories: ["AI", "EIP"] +keywords: ["apache camel", "semantic", "a2a", "switch", "split", "jev", "laya", "julia", "camel tui"] +preview: "Bring agent coordination into small, readable Camel routes: semantic decisions, parallel A2A calls, and bounded retries using familiar EIPs." +--- + +> “Will it rain in Lisbon on Tuesday, and how much is an SUV for five days?” + +It is one customer message, but it needs two answers. A weather specialist can handle the first part. A pricing specialist can handle the second. Something still has to select both, call them, check their contributions, and return one useful reply. + +That sounds like an integration problem to me. + +Think of this as part two of my [earlier post on semantic decisions in Camel](/blog/2026/09/semantic-evaluation-system-one/). That post introduced `camel-semantic` with Jev for routing and validation. Here, I use those decisions to coordinate a team of specialist agents: choosing who should answer, calling them in parallel, and checking their replies. + +This post is inspired by **Kevin Dubois's two articles on Quarkus, LangChain4j, and Jev**: [routing agents with Jev and Laya](https://www.kevindubois.com/2026/09/23/routing-agents-with-jev-and-laya-adding-system-one-decisions-to-quarkus-langchain4j/) and [multi-intent requests and fan-out](https://www.kevindubois.com/2026/10/01/routing-with-jev-and-langchain4j-agentic-part-2-the-real-api-multi-intent-requests-and-fan-out/). + +Kevin's car rental scenario brings together specialist selection, reply checks, and requests that need more than one agent. I wanted to bring those ideas into one example built with Camel throughout. + +**With Camel, the same coordination ideas become small, readable routes.** A **Split** expresses parallel work. A **Switch** maps a decision to a destination. A **Loop** expresses another attempt. For me, that is the elegance of this approach: the route reads like the workflow it implements. + +The coordinator is a Camel application, every specialist is a Camel application, and their conversations use Camel's A2A component. Selecting destinations, collecting replies, and deciding whether to retry are all visible in the routes. + +The result is the [`semantic-agent-routing` example](https://github.com/apache/camel-examples/tree/main/semantic-agent-routing). It combines the [Semantic language](/components/next/languages/semantic-language.html), [A2A component](/components/next/a2a-component.html), and familiar Enterprise Integration Patterns. A decision model evaluates the questions; Camel owns the destinations, parallel work, retries, and response handling. + +> **Version:** This example uses **Camel 4.23.0-SNAPSHOT**, including batched semantic evaluation and the new Switch EIP. The Semantic language has Preview support status. + +## Five Camel applications, two kinds of model calls + +There is one coordinator and four specialists: reservation, weather, cost, and general help. Each specialist is a separate Camel application exposing an A2A endpoint. + +The coordinator receives plain text at `POST /trip`. From there, the flow is: + +| Step | What Camel does | +| --- | --- | +| Select | Ask which specialties the request needs, using `camel-semantic`. | +| Call | Split the selected names and call the specialists in parallel through `camel-a2a`. | +| Check | Evaluate each reply against that specialist's part of the request. | +| Combine | Return one accepted reply directly, or merge several with `camel-openai`. | +| Check again | Evaluate the merged answer against the whole request. | + +The model calls serve different purposes: + +- **The System One model makes decisions:** it returns typed answers and probabilities. +- **The Large Language Model (LLM) writes replies:** it generates each specialist's contribution and the combined answer. + +The routes keep both services configurable. + +The specialists use deliberately small, fixed demo facts. Lisbon has a Tuesday forecast of light rain and 18°C; an SUV costs €75 per day. They do not look up live weather or make bookings. That keeps the example focused on how Camel coordinates the work. + +{{< figure src="tui-overview.svg" link="tui-overview.svg" alt="Camel TUI listing the coordinator and four specialist applications" caption="**Five applications, all Camel.** The coordinator and four specialists running with Camel JBang. Click any screenshot to enlarge it." >}} + +## Ask which specialists are needed + +A single category cannot describe every request. Asking “which specialist?” forces the weather-and-price question into one bucket, even when the model is confident about its choice. + +The coordinator therefore asks three independent Boolean questions: does this request need reservation, weather, or cost information? Here is the weather definition from the YAML: + +```yaml +needsWeather: + type: boolean + instructions: Does answering this customer request fully require input about weather, rain, forecasts or driving conditions at the destination? + threshold: "{{routing.fan-out-threshold}}" +``` + +The default threshold is `0.5`. Each specialist whose probability meets that threshold is selected. These are independent questions, so weather and cost can both match. + +A fourth question, named `specialist`, asks for one of `reservation`, `weather`, `cost`, or `general`. It is a fallback when none of the independent questions matches. The fallback also has a minimum selected-option probability, `0.55`; below it, the coordinator asks the customer to clarify. + +### One batch, four answers + +All four questions are evaluated together in `select-specialists`: + +```yaml +- setProperty: + name: routingDecisions + language: + language: semantic + expression: refs:specialist,needsReservation,needsWeather,needsCost +``` + +`refs:` returns a map of named answers. With the TypeSafe AI adapter, this batch goes to the decision service in one HTTP request. The result metadata, including the Choice probabilities, is available in `CamelSemanticResults`. + +A small plain Java bean, `TripSupport`, turns those answers into a list such as `[weather, cost]`. It has no Camel dependencies: it accepts maps and numbers and returns names or action labels. I kept this policy in Java because a few ordinary `if` statements are easier to read than a long expression embedded in YAML. + +**Choice confidence does not control this selection.** A confident single-label answer still does not tell us whether the request contains a second topic. + +## Split the work, then dispatch with Switch + +### Call the selected specialists in parallel + +Once the coordinator has the specialist list, the parallel work is a [Split EIP](/components/next/eips/split-eip.html): + +**In `answer-trip`:** + +```yaml +- split: + expression: + exchangeProperty: specialists + parallelProcessing: true + stopOnException: true + aggregationStrategy: "#class:org.apache.camel.processor.aggregate.GroupedBodyAggregationStrategy" + steps: + - setProperty: + name: specialist + simple: "${body}" + - to: direct:answer-specialist +``` + +Each branch generates and checks one specialist's reply. Camel's grouped-body aggregation collects the results when the branches finish. The merge waits for those results; it does not run alongside the specialists. + +{{< figure src="tui-fan-out.svg" link="tui-fan-out.svg" alt="Camel TUI showing the answer-trip route with two specialist exchanges through Split and one through the selected merge case" caption="**`answer-trip` in Camel TUI.** The Split sends two exchanges to the specialist routes. After their replies are collected, the Switch selects `merge` once. Its details are shown on the left." >}} + +### Give each name a fixed destination + +Inside each branch, the [Switch EIP](/components/next/eips/switch-eip.html) maps the selected name to a configured A2A endpoint: + +**In `specialist-dispatch`:** + +```yaml +- switch: + selector: + exchangeProperty: + expression: specialist + case: + - value: reservation + uri: "a2a:{{agents.reservation.url}}?protocolBinding=JSONRPC&connectTimeout=5000" + - value: weather + uri: "a2a:{{agents.weather.url}}?protocolBinding=JSONRPC&connectTimeout=5000" + - value: cost + uri: "a2a:{{agents.cost.url}}?protocolBinding=JSONRPC&connectTimeout=5000" + - value: general + uri: "a2a:{{agents.general.url}}?protocolBinding=JSONRPC&connectTimeout=5000" + otherwise: + uri: direct:unsupported-specialist +``` + +This is a good fit for Switch: one value selects one destination from a fixed table. The model supplies a label; the route author supplies the endpoints. + +{{< figure src="tui-dispatch.svg" link="tui-dispatch.svg" alt="Camel TUI showing the specialist-dispatch Switch with the weather case selected and weather and cost called once each" caption="**`specialist-dispatch` in Camel TUI.** The Switch mirrors the destination table in the YAML. Weather and cost each receive one message; selecting the weather case reveals its counters and timing." >}} + +## A specialist is a small Camel route + +The other side of the A2A call is also Camel. Here is the weather specialist, with its prompt shortened for the article: + +```yaml +- route: + id: weather-agent + from: + uri: a2a:weather + parameters: + name: weather + description: Camel car rental weather specialist using demonstration data + protocolBinding: JSONRPC + httpServerComponent: platform-http + validateAuth: false + steps: + - to: + uri: openai:chat-completion + parameters: + temperature: "{{openai.temperature}}" + requestTimeout: "{{openai.request-timeout}}" + systemMessage: >- + You are the weather specialist for a car rental demonstration. + Answer only the weather part of the request and label facts as demo data. + Demo forecast (not live): Lisbon, Tuesday, light rain, 18 C. + No forecast is available for other places or dates. +``` + +The A2A consumer exposes the agent and its card; the producer handles discovery and the protocol calls. The route can concentrate on preparing an answer. All servers bind to loopback, and authentication is disabled for this local demo. + +The [OpenAI component](/components/next/openai-component.html) reads the shared API key, base URL, and model from `application.properties`. Each specialist only supplies its own prompt and generation options. Gemini works through its [OpenAI-compatible endpoint](https://ai.google.dev/gemini-api/docs/openai), so changing the generation provider does not require rewriting these routes. + +## Check each contribution, then the whole answer + +### Generate and check in a Loop + +Camel runs generation and checking in a [Loop EIP](/components/next/eips/loop-eip.html). The two named routes make the sequence easy to follow: + +**In `answer-specialist`:** + +```yaml +- setProperty: + name: generateAgain + constant: + expression: "true" + resultType: boolean +- loop: + doWhile: true + simple: "${exchangeProperty.generateAgain}" + steps: + - to: direct:generate-specialist-reply + - to: direct:check-specialist-reply +``` + +The checking route updates `generateAgain`: only a clear rejection with attempts remaining asks the Loop to run again. + +{{< figure src="tui-answer-specialist.svg" link="tui-answer-specialist.svg" alt="Camel TUI showing the answer-specialist Loop around generation and relevance checking, with two completed exchanges and no failures" caption="**`answer-specialist` in Camel TUI.** The Loop contains the same two calls as the YAML above. Both specialists passed on their first attempt in this capture, so the selected Loop reports two completed exchanges in total." >}} + +### Accept, retry, or ask for review + +A weather reply should answer the weather question. It should not fail merely because it leaves pricing to the cost specialist. + +The coordinator passes the original request, specialist name, and draft reply to a semantic question called `relevant`: + +```yaml +relevant: + type: boolean + instructions: >- + Does the drafted reply address every requested part within the named specialist's + role? A helpful greeting or a request for missing details can address the customer. + The specialist need not answer other specialists' topics, but must not omit items + belonging to its own specialty. + state: "${exchangeProperty.validationState}" + threshold: 0.5 + uncertainty: 0.1 + uncertaintyPolicy: non-match +``` + +The threshold and uncertainty define an explicit policy: + +| Probability | Action | +| --- | --- | +| Above `0.6` | Accept the draft. | +| From `0.4` through `0.6` | Return a review response. | +| Below `0.4` | Retry if the attempt budget allows; otherwise return a review response. | + +These are demo settings, not universal quality boundaries. The default budget is **three generation attempts, including the first**. A retry includes the previous draft and correction feedback. + +`TripSupport.draftAction(...)` returns `accept`, `retry`, or `review`, and another Switch sends that action to a short, named route. **Uncertain or exhausted drafts are withheld** from the public response. + +### Combine the accepted contributions + +When all specialist branches finish, deciding what to do with their replies is similarly compact: + +**Back in `answer-trip`:** + +```yaml +- switch: + selector: + method: + ref: tripSupport + method: replyAction + case: + - value: review + uri: direct:review-replies + - value: single + uri: direct:use-single-reply + - value: merge + uri: direct:merge-replies +``` + +This is the Switch at the bottom of the [`answer-trip` screenshot](tui-fan-out.svg): + +- **`review`** — withhold the answer if any contribution was not accepted. +- **`single`** — return the one accepted contribution directly. +- **`merge`** — ask the chat model to combine several accepted contributions with the original request. + +That combined answer gets a different question: **does it address every part of the original request?** If it clearly fails, Camel retries the merge within its own budget, using the replies already collected. It does not call all the specialists again. + +Service failures follow the HTTP error path and return `502`; they do not consume semantic regeneration attempts. Invalid input returns `400`. Scoped `onException` handling keeps that HTTP response logic out of the main workflow. + +## Change the decision service through configuration + +The coordinator uses `camel-semantic` with the TypeSafe AI adapter. Hosted Jev implements that contract. The example also includes configurations for separate [Laya](https://github.com/luigidemasi/laya) and [Julia-1](https://github.com/luigidemasi/julia1) container projects, with compatible HTTP responses. + +The containers live outside the Camel example and can be reused independently. The routes read the same settings for every compatible service: + +```properties +camel.component.typesafe-ai.base-url={{env:DECISION_BASE_URL:http://127.0.0.1:8100}} +camel.component.typesafe-ai.api-path={{env:DECISION_API_PATH:/v1/systemone}} +camel.component.typesafe-ai.api-key={{env:DECISION_API_KEY:local-demo}} +camel.component.typesafe-ai.model={{env:DECISION_MODEL:jev-latest}} +``` + +The configurable API path lets a local service expose `/v1/decision`, as in Kevin's original example, while hosted Jev uses `/v1/systemone`. + +Compatibility here means the request and response contract, including named batch answers and probability metadata. It does not mean the models make the same decisions. A service with a different protocol needs a semantic adapter. + +## Run it and follow it in Camel TUI + +The example needs **Java 21+ and Camel JBang**. The [README](https://github.com/apache/camel-examples/blob/main/semantic-agent-routing/README.adoc) has the complete provider recipes. + +### 1. Configure the two services + +From the example directory, copy `camel-agent-routing.env.example` to `camel-agent-routing.env` and fill in your keys and provider settings. Git ignores this local file. For the hosted setup used here, use: + +```sh +OPENAI_API_KEY='your-api-key' +OPENAI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/ +OPENAI_MODEL=gemini-3.1-flash-lite + +JEV_API_KEY='your-jev-key' +DECISION_BASE_URL=https://api.typesafe.ai +DECISION_API_PATH=/v1/systemone +DECISION_MODEL=jev-latest +DECISION_TIMEOUT=5000 +DECISION_API_KEY=${JEV_API_KEY} +``` + +`OPENAI_API_KEY` is the variable the example reads for generation; in this configuration it holds the API key. The decision service has its own key. Review Comment: "it holds the API key" reads a bit circular — I think this means the Gemini key: ```suggestion `OPENAI_API_KEY` is the variable the example reads for generation; in this configuration it holds your Gemini API key. The decision service has its own key. ``` -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
