diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2c36de86d9..d608912324 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,11 +8,15 @@ on: - release-* tags: - v* + paths-ignore: + - 'quarkus/**' pull_request: branches: - master - release-* + paths-ignore: + - 'quarkus/**' jobs: test: diff --git a/.github/workflows/quarkus-build.yml b/.github/workflows/quarkus-build.yml new file mode 100644 index 0000000000..4ed19745dd --- /dev/null +++ b/.github/workflows/quarkus-build.yml @@ -0,0 +1,82 @@ +name: Quarkus Agentic Build + +on: + workflow_dispatch: + push: + branches: + - master + - release-quarkus-* + paths: + - 'quarkus/**' + tags: + - quarkus-v* + + pull_request: + branches: + - master + - release-quarkus-* + paths: + - 'quarkus/**' + +jobs: + build: + name: "Build & Test" + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + JDK_VER: 17 + steps: + - uses: actions/checkout@v5 + - name: Set up OpenJDK ${{ env.JDK_VER }} + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: ${{ env.JDK_VER }} + - name: Build quarkus modules + run: | + cd quarkus + ../mvnw clean install -B -q -DskipITs=true + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: quarkus-test-reports + path: quarkus/**/target/surefire-reports/ + + publish: + runs-on: ubuntu-latest + needs: [ build ] + timeout-minutes: 30 + env: + JDK_VER: 17 + OSSRH_USER_TOKEN: ${{ secrets.OSSRH_USER_TOKEN }} + OSSRH_PWD_TOKEN: ${{ secrets.OSSRH_PWD_TOKEN }} + GPG_KEY: ${{ secrets.GPG_KEY }} + GPG_PWD: ${{ secrets.GPG_PWD }} + steps: + - uses: actions/checkout@v5 + - name: Set up OpenJDK ${{ env.JDK_VER }} + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: ${{ env.JDK_VER }} + - name: Get quarkus version + run: | + QUARKUS_VERSION=$(./mvnw -B -q -Dexec.executable=echo -Dexec.args='${project.version}' --non-recursive exec:exec -f quarkus/pom.xml) + echo "QUARKUS_VERSION=$QUARKUS_VERSION" >> $GITHUB_ENV + - name: Is SNAPSHOT release ? + if: contains(github.ref, 'master') && contains(env.QUARKUS_VERSION, '-SNAPSHOT') + run: | + echo "DEPLOY_OSSRH=true" >> $GITHUB_ENV + - name: Is Release version ? + if: startsWith(github.ref, 'refs/tags/quarkus-v') && !contains(env.QUARKUS_VERSION, '-SNAPSHOT') + run: | + echo "DEPLOY_OSSRH=true" >> $GITHUB_ENV + - name: Publish to ossrh + if: env.DEPLOY_OSSRH == 'true' + run: | + echo ${{ secrets.GPG_PRIVATE_KEY }} | base64 -d > private-key.gpg + export GPG_TTY=$(tty) + gpg --batch --import private-key.gpg + cd quarkus + ../mvnw -V -B -Dgpg.skip=false -DskipTests -s ../settings.xml deploy diff --git a/.github/workflows/quarkus-release.yml b/.github/workflows/quarkus-release.yml new file mode 100644 index 0000000000..92228ce5a9 --- /dev/null +++ b/.github/workflows/quarkus-release.yml @@ -0,0 +1,55 @@ +name: Quarkus Agentic Release + +on: + workflow_dispatch: + inputs: + rel_version: + description: 'Release version (examples: 0.1.0, 0.2.0-rc-1, 0.2.0-SNAPSHOT)' + required: true + type: string + +jobs: + create-release: + name: Creates quarkus release tag + runs-on: ubuntu-latest + env: + JDK_VER: '17' + steps: + - name: Check out code + uses: actions/checkout@v5 + with: + fetch-depth: 0 + token: ${{ secrets.DAPR_BOT_TOKEN }} + persist-credentials: false + - name: Set up OpenJDK ${{ env.JDK_VER }} + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: ${{ env.JDK_VER }} + - name: Update quarkus version and tag + env: + GITHUB_TOKEN: ${{ secrets.DAPR_BOT_TOKEN }} + REL_VERSION: ${{ inputs.rel_version }} + run: | + set -ue + + git config user.email "daprweb@microsoft.com" + git config user.name "Dapr Bot" + git remote set-url origin https://x-access-token:${{ secrets.DAPR_BOT_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git + + # Update version in all quarkus pom.xml files + cd quarkus + ../mvnw versions:set -DnewVersion=$REL_VERSION -DprocessDependencies=true + cd .. + + if [[ "$REL_VERSION" == *-SNAPSHOT ]]; then + git commit -s -m "Update quarkus version to ${REL_VERSION}" -a + git push origin master + echo "Updated master with quarkus version ${REL_VERSION}." + else + git commit -s -m "Release quarkus-v${REL_VERSION}" -a + git tag "quarkus-v${REL_VERSION}" + git push origin master + git push origin "quarkus-v${REL_VERSION}" + echo "Tagged and pushed quarkus-v${REL_VERSION}." + fi diff --git a/.github/workflows/validate-docs.yml b/.github/workflows/validate-docs.yml index 6e07dd0ed5..70011c79d0 100644 --- a/.github/workflows/validate-docs.yml +++ b/.github/workflows/validate-docs.yml @@ -8,11 +8,15 @@ on: - release-* tags: - v* + paths-ignore: + - 'quarkus/**' pull_request: branches: - master - release-* + paths-ignore: + - 'quarkus/**' jobs: build: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index da57adef3a..1e5d60dac3 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -19,11 +19,15 @@ on: - release-* tags: - v* + paths-ignore: + - 'quarkus/**' pull_request: branches: - master - release-* + paths-ignore: + - 'quarkus/**' jobs: validate: runs-on: ubuntu-latest diff --git a/pom.xml b/pom.xml index f039e6254c..ecbfb78416 100644 --- a/pom.xml +++ b/pom.xml @@ -749,6 +749,7 @@ testcontainers-dapr durabletask-client + quarkus diff --git a/quarkus/README.md b/quarkus/README.md new file mode 100644 index 0000000000..e2103f0074 --- /dev/null +++ b/quarkus/README.md @@ -0,0 +1,192 @@ +# Quarkus Agentic Dapr + +A Quarkus extension that bridges [LangChain4j's agentic framework](https://docs.langchain4j.dev/) with [Dapr Workflows](https://docs.dapr.io/developing-applications/building-blocks/workflow/workflow-overview/) for durable, observable AI agent orchestration. + +## What it does + +The agent's **ReAct loop runs _as_ a Dapr Workflow** (control inversion): the only +non-deterministic steps — each model call and each tool call — are workflow activities, so all +agent state lives in the workflow history. Crash recovery, horizontal scale, and observability +fall out for free. Composites (sequential, parallel, loop, conditional) are themselves workflows +that call their children directly, forming a replayable parent-child tree. + +| Capability | Without Dapr | With `quarkus-langchain4j-dapr-agentic` | +|---|---|---| +| Durability | Lost on crash | Full workflow history persisted | +| Crash recovery | Restart from scratch | Resume at the next un-run model/tool call | +| Horizontal scale | Single process | Activities placed across replicas (no in-memory state) | +| Observability | Logs only | Dapr dashboard + per-activity tracking | +| Tool / LLM call audit trail | None | Every request/response recorded in history | +| Code changes | — | **None** — just add `quarkus-langchain4j-dapr-agentic` | + +## Modules + +``` +quarkus/ +├── quarkus-langchain4j-dapr-agentic/ # The extension: agents as durable Dapr Workflows +│ ├── runtime/ # durable agent workflows + activities +│ └── deployment/ # annotation scanning, durable agent proxies +├── quarkus-langchain4j-dapr-llm/ # Optional: Dapr Conversation API as ChatModel provider +├── quarkus-langchain4j-dapr-registry/ # Optional: registers agents in Dapr state store +└── examples/ # Built-in examples +``` + +## Supported Agent Types + +All 5 LangChain4j orchestration types are supported: + +| Annotation | Dapr Workflow | +|------------|---------------| +| `@Agent` | `react-agent` (the agent's ReAct loop run as a workflow) | +| `@SequenceAgent` | `durable-sequence` | +| `@ParallelAgent` | `durable-parallel` | +| `@LoopAgent` | `durable-loop` | +| `@ConditionalAgent` | `durable-conditional` | + +Composites can be nested arbitrarily (e.g. a `@SequenceAgent` whose sub-agent is a +`@ParallelAgent`): each composite completes with its full shared-state map, which its parent +merges, so state propagates across the whole tree. Structured (`@Output`) combiners and +record return types are supported. + +## Quick Start + +### 1. Add the dependency + +```xml + + io.dapr.quarkus + quarkus-langchain4j-dapr-agentic + 0.1.0-SNAPSHOT + +``` + +### 2. Configure + +```properties +quarkus.dapr.devservices.enabled=false +quarkus.dapr.workflow.enabled=false + +# Dapr sidecar endpoints +dapr.grpc.endpoint=${DAPR_GRPC_ENDPOINT:http://localhost:40001} +dapr.http.endpoint=${DAPR_HTTP_ENDPOINT:http://localhost:3500} +``` + +### 3. Write agents (standard LangChain4j — no Dapr-specific code) + +```java +public interface WeatherAssistant { + + @ToolBox(WeatherTools.class) + @UserMessage("Check the weather in {{city}}") + @Agent(name = "weather-assistant", outputKey = "weather") + String checkWeather(@V("city") String city); +} +``` + +### 4. Run with Dapr + +```bash +# Terminal 1: Dapr sidecar +dapr run --app-id my-agent --dapr-grpc-port 40001 --dapr-http-port 3500 \ + --resources-path ./components + +# Terminal 2: Quarkus app +DAPR_GRPC_ENDPOINT=http://localhost:40001 \ +DAPR_HTTP_ENDPOINT=http://localhost:3500 \ +mvn quarkus:dev -Ddebug=false +``` + +## LLM Provider Options + +### Ollama (direct) +```properties +quarkus.langchain4j.ollama.chat-model.model-id=llama3.1:8b +``` + +### Dapr Conversation API (provider-agnostic) +```properties +quarkus.langchain4j.chat-model.provider=dapr-conversation +quarkus.langchain4j.dapr.component-name=llm +``` + +Swap LLM providers by changing the Dapr component YAML — no Java code changes. + +## Example Project + +See [travel-planner-agents](https://github.com/javier-aliaga/travel-planner-agents) for a complete example with all agent types, Makefile targets, and Dapr components. + +## Crash Recovery + +Because the ReAct loop _is_ a workflow, recovery is the same deterministic replay Dapr gives any +workflow — there is no special recovery path. If the process crashes mid-execution, every model +call and tool call that already completed returns from the workflow history on replay, and the +loop resumes at the next un-run call. Recovery is **per call**, not per agent. + +### How it works + +1. **Normal operation**: `ReActAgentWorkflow` drives the loop. Each model call (`agent-llm`) and + tool call (`agent-tool`) is a workflow activity whose result is recorded in history. +2. **Crash**: The process dies. Dapr workflow history persists. +3. **Restart**: Dapr replays the workflow. Completed `agent-llm`/`agent-tool` activities return + their recorded results instantly; the loop is deterministic given those results. +4. **Resume**: The first activity with no recorded result is the only one that actually re-runs — + the loop continues from exactly where it stopped. The same applies to composite children + (a completed child workflow is not re-executed). + +### Recovery granularity + +| Scope | Behavior | +|-------|----------| +| Composite (e.g., Agent1 → Agent2 → Agent3) | Completed child workflows are skipped on replay; only the in-progress child resumes. | +| Single agent (LLM calls + tool calls) | Completed model/tool calls return from history; only the next un-run call executes. | + +### Demo: simulating a crash + +```bash +# 1. Start the app and trigger a multi-agent workflow +curl "http://localhost:8080/travel/plan?origin=NYC&destination=Paris" + +# 2. Kill the process mid-execution (e.g., during the second agent) +kill -9 + +# 3. Restart the app — the workflow resumes automatically +mvn quarkus:dev +``` + +In the Dapr dashboard, completed activities show recorded results; the workflow resumes at the +next un-run call and completes. + +### Key classes + +| Class | Role | +|-------|------| +| `ReActAgentWorkflow` | The agent's ReAct loop run as a workflow (`react-agent`) | +| `AgentLlmActivity` | One model call (`agent-llm`) — a pure function of the conversation + agent name | +| `AgentToolActivity` | One `@Tool` invocation (`agent-tool`) | +| `ToolRegistry` | CDI bean that discovers `@Tool` methods at startup | +| `AgentToolClassRegistry` | Maps agent names to their `@ToolBox` classes (populated at build time) | + +## AgenticScope Checkpointing + +LangChain4j's agentic scope (the shared state and conversation context of a multi-agent +workflow) can be checkpointed to a Dapr state store — the LangChain4j equivalent of a +LangGraph checkpointer. Every scope update is persisted, so agentic state survives +restarts and is shareable across replicas. + +```properties +dapr.agentic.scope-store.enabled=true +dapr.agentic.scope-store.name=kvstore +``` + +| Class | Role | +|-------|------| +| `DaprAgenticScopeStore` | `AgenticScopeStore` implementation over the Dapr state API | +| `AgenticScopeStoreInitializer` | Registers the store with LangChain4j's `AgenticScopePersister` at startup | + +## Known Limitations + +- **At-least-once activities**: `agent-tool` activities can be redelivered on retry/replay, so + side-effecting tools must be idempotent or externally guarded. +- **`ResultWithAgenticScope` return type**: not yet supported — the durable engine has no + in-memory `AgenticScope` to surface. Use a plain return type or an `@Output` combiner. +- **Small models**: llama3.2 (3B) sometimes malforms tool call arguments; llama3.1:8b+ recommended diff --git a/quarkus/examples/DURABLE.md b/quarkus/examples/DURABLE.md new file mode 100644 index 0000000000..8d9238f2b2 --- /dev/null +++ b/quarkus/examples/DURABLE.md @@ -0,0 +1,71 @@ +# Durable agents (control-inversion approach) + +This branch (`control-inversion`) runs an agent's ReAct loop **as a Dapr Workflow** +(`ReActAgentWorkflow`) instead of recording an in-memory LangChain4j AiServices loop. The +model call (`agent-llm`) and each tool call (`agent-tool`) are activities — pure functions +of their input, with no in-memory run context. That single change is what makes per-call +crash recovery and horizontal scale possible. See the architecture notes in `../README.md`. + +## What the automated tests prove + +Run with Docker available (dev services start Dapr + placement + scheduler + state store): + +```bash +mvn test -Dtest=DurableAgentResourceTest,DurableToolAgentResourceTest +``` + +- **`DurableAgentResourceTest`** (`GET /durable`) — a single agent's loop runs as the + `react-agent` workflow; the model call executes as the `agent-llm` activity and the run + completes from workflow history. +- **`DurableToolAgentResourceTest`** (`GET /durable/research`) — a tool round-trip: + `agent-llm` requests a tool → `agent-tool` executes the real `ResearchTools.getCapital` + → result flows back → `agent-llm` produces the final answer. Asserting the response + contains `Paris` proves the tool ran via the activity. + +The two demos below need **multiple processes**, so they're run by hand rather than in the +single-JVM test harness. + +## Demo 1 — per-call crash recovery + +Shows that a crash mid-loop resumes from workflow history: completed LLM/tool calls are +**not** re-executed (contrast with the passive-recorder approach, which re-runs the whole +agent from scratch and loses template variables). + +```bash +# Terminal 1 — standalone Dapr so workflow state persists across an app restart +dapr run --app-id durable-agents --resources-path ./components -- \ + mvn quarkus:dev + +# Terminal 2 — start a multi-step (tool-using) run +curl "http://localhost:8080/durable/research?country=France" + +# Terminal 2 — while it is between the agent-llm and agent-tool steps, kill the app +# (find the quarkus:dev / java PID and: kill -9 ) + +# Terminal 1 — restart; Dapr replays the workflow from history +mvn quarkus:dev +``` + +On restart the workflow rehydrates: already-completed `agent-llm`/`agent-tool` activities +return from history (visible in the Dapr dashboard as cached), and the loop continues at +the next un-run step. Nothing is re-executed and no prompt/state is lost. + +## Demo 2 — horizontal scale (activities across replicas) + +Shows the run executing across **two** replicas. Dapr Workflow +[randomly distributes activity work across all replicas](https://docs.dapr.io/developing-applications/building-blocks/workflow/workflow-architecture/) +of an app-id — which is correct here because the activities carry no in-memory state (the +passive-recorder approach is single-replica-only for exactly this reason). + +```bash +# Shared placement + scheduler + state store, then two app instances on the same app-id. +# (Point both at the same Dapr control plane; e.g. two `dapr run --app-id durable-agents` +# on different app ports against a shared placement/scheduler, or two replicas on k8s.) + +# Fire several requests +for i in 1 2 3 4 5; do curl -s "http://localhost:8080/durable/research?country=France" & done; wait +``` + +Watch each instance's logs: `Processing activity request: agent-llm` / `agent-tool` lines +appear on **both** replicas for the same workflow instance — work is load-balanced, and any +replica can run any activity because none of them depend on a node-local `AgentRunContext`. diff --git a/quarkus/examples/pom.xml b/quarkus/examples/pom.xml new file mode 100644 index 0000000000..26c585eddc --- /dev/null +++ b/quarkus/examples/pom.xml @@ -0,0 +1,101 @@ + + + 4.0.0 + + io.dapr.quarkus + dapr-quarkus-agentic-parent + 0.1.0-SNAPSHOT + ../pom.xml + + + quarkus-langchain4j-dapr-examples + Quarkus LangChain4j Dapr - Examples + + + + + io.dapr.quarkus + quarkus-langchain4j-dapr-agentic + ${project.version} + + + + io.dapr.quarkus + quarkus-langchain4j-dapr-registry + ${project.version} + + + + + io.quarkiverse.langchain4j + quarkus-langchain4j-openai + ${quarkus-langchain4j.version} + + + + + io.quarkus + quarkus-rest + + + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + org.awaitility + awaitility + test + + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.version} + + + + build + + + + + + maven-compiler-plugin + 3.13.0 + + + -parameters + + + + + maven-surefire-plugin + 3.5.2 + + + org.jboss.logmanager.LogManager + + + 1 + + + + + diff --git a/quarkus/examples/src/main/java/io/dapr/quarkus/examples/CreativeWriter.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/CreativeWriter.java new file mode 100644 index 0000000000..3ef7157986 --- /dev/null +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/CreativeWriter.java @@ -0,0 +1,35 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.examples; + +import dev.langchain4j.agentic.Agent; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; +import io.quarkiverse.langchain4j.ToolBox; + +/** + * Sub-agent that generates a creative story draft based on a given topic. + */ +public interface CreativeWriter { + + @UserMessage(""" + You are a creative writer. + Generate a draft of a story no more than 3 sentences around the given topic. + Return only the story and nothing else. + The topic is {{topic}}. + """) + @Agent(name = "creative-writer-agent", + description = "Generate a story based on the given topic", outputKey = "story") + String generateStory(@V("topic") String topic); +} diff --git a/quarkus/examples/src/main/java/io/dapr/quarkus/examples/DurableAgentResource.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/DurableAgentResource.java new file mode 100644 index 0000000000..081074ca86 --- /dev/null +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/DurableAgentResource.java @@ -0,0 +1,94 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.examples; + +import io.dapr.quarkus.langchain4j.durable.ReActInput; +import io.dapr.workflows.client.DaprWorkflowClient; +import io.dapr.workflows.client.WorkflowInstanceStatus; +import jakarta.inject.Inject; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; + +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.TimeoutException; + +/** + * Demonstrates starting the durable {@link io.dapr.quarkus.langchain4j.durable.ReActAgentWorkflow} + * directly via the Dapr workflow client (the agent's ReAct loop runs as a workflow). + * + *

Composites are exercised through the drop-in entry point (just inject {@code @SequenceAgent} + * etc.); these endpoints only show the leaf react-agent path. + * + *

+ * curl "http://localhost:8080/durable?topic=dragons"
+ * curl "http://localhost:8080/durable/research?country=France"
+ * 
+ */ +@Path("/durable") +public class DurableAgentResource { + + @Inject + DaprWorkflowClient workflowClient; + + /** + * Starts the durable ReAct workflow for a single creative-writer agent and returns its result. + * + * @param topic the story topic + * @return the generated story text + * @throws TimeoutException if the workflow does not complete within the wait window + */ + @GET + @Produces(MediaType.TEXT_PLAIN) + public String run(@QueryParam("topic") @DefaultValue("dragons and wizards") String topic) + throws TimeoutException { + String userMessage = "You are a creative writer. Generate a draft of a story no more than " + + "3 sentences around the topic '" + topic + "'. Return only the story and nothing else."; + return runReactAgent("creative-writer-agent", userMessage, "durable-"); + } + + /** + * Starts the durable ReAct workflow for a tool-using research agent and returns its result. + * + *

Exercises the {@code agent-tool} activity: the model requests a tool call, the tool runs + * as a replica-agnostic activity, and its result is fed back into the loop. + * + * @param country the country to research + * @return the research summary + * @throws TimeoutException if the workflow does not complete within the wait window + */ + @GET + @Path("/research") + @Produces(MediaType.TEXT_PLAIN) + public String research(@QueryParam("country") @DefaultValue("France") String country) + throws TimeoutException { + String userMessage = "You are a research assistant. Write a concise summary about the country " + + country + " using the available tools. Return only the summary."; + return runReactAgent("research-location-agent", userMessage, "durable-research-"); + } + + private String runReactAgent(String agentName, String userMessage, String idPrefix) + throws TimeoutException { + ReActInput input = new ReActInput(agentName, null, userMessage, null, null, 8); + String instanceId = idPrefix + UUID.randomUUID(); + workflowClient.scheduleNewWorkflow("react-agent", input, instanceId); + WorkflowInstanceStatus status = + workflowClient.waitForInstanceCompletion(instanceId, Duration.ofSeconds(60), true); + return status.readOutputAs(String.class); + } +} diff --git a/quarkus/examples/src/main/java/io/dapr/quarkus/examples/LoopResource.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/LoopResource.java new file mode 100644 index 0000000000..de95b865bd --- /dev/null +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/LoopResource.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.examples; + +import jakarta.inject.Inject; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; + +/** + * REST endpoint that triggers the loop creation workflow. + * + *

Runs {@link CreativeWriter} and {@link StyleEditor} twice in a loop via a + * {@code LoopOrchestrationWorkflow} Dapr Workflow. + * + *

Example usage: + *

+ * curl "http://localhost:8080/loop?topic=dragons&style=comedy"
+ * 
+ */ +@Path("/loop") +public class LoopResource { + + @Inject + LoopWriter loopWriter; + + @GET + @Produces(MediaType.TEXT_PLAIN) + public String create( + @QueryParam("topic") @DefaultValue("dragons and wizards") String topic, + @QueryParam("style") @DefaultValue("comedy") String style) { + return loopWriter.write(topic, style); + } +} diff --git a/quarkus/examples/src/main/java/io/dapr/quarkus/examples/LoopWriter.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/LoopWriter.java new file mode 100644 index 0000000000..b0701a1777 --- /dev/null +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/LoopWriter.java @@ -0,0 +1,33 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.examples; + +import dev.langchain4j.agentic.declarative.LoopAgent; +import dev.langchain4j.service.V; + +/** + * Composite agent that runs {@link CreativeWriter} and {@link StyleEditor} in a loop + * (2 iterations), backed by a {@code LoopOrchestrationWorkflow} Dapr Workflow. + * + *

The same sub-agents execute in every iteration, which exercises the per-iteration + * agent-run bookkeeping (registry entries, name bindings, completion routing). + */ +public interface LoopWriter { + + @LoopAgent(name = "loop-writer-agent", + outputKey = "story", + maxIterations = 2, + subAgents = { CreativeWriter.class, StyleEditor.class }) + String write(@V("topic") String topic, @V("style") String style); +} diff --git a/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ParallelCreator.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ParallelCreator.java new file mode 100644 index 0000000000..16648cdbd4 --- /dev/null +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ParallelCreator.java @@ -0,0 +1,50 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.examples; + +import dev.langchain4j.agentic.declarative.Output; +import dev.langchain4j.agentic.declarative.ParallelAgent; +import dev.langchain4j.service.V; + +/** + * Composite agent that orchestrates {@link StoryCreator} and {@link ResearchWriter} + * in parallel, backed by a Dapr Workflow. + * + *

Both sub-agents execute concurrently via a {@code ParallelOrchestrationWorkflow}. + * {@link StoryCreator} is itself a {@code @SequenceAgent} that chains + * {@link CreativeWriter} and {@link StyleEditor} — demonstrating nested composite agents. + * Meanwhile {@link ResearchWriter} gathers facts about the country. + */ +public interface ParallelCreator { + + @ParallelAgent(name = "parallel-creator-agent", + outputKey = "storyAndCountryResearch", + subAgents = { StoryCreator.class, ResearchWriter.class }) + ParallelStatus create(@V("topic") String topic, @V("country") String country, @V("style") String style); + + /** + * Produces the final output from the parallel agent results. + * + * @param story the generated story + * @param summary the generated summary + * @return the combined parallel status + */ + @Output + static ParallelStatus output(String story, String summary) { + if (story == null || summary == null) { + return new ParallelStatus("ERROR", story, summary); + } + return new ParallelStatus("OK", story, summary); + } +} diff --git a/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ParallelResource.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ParallelResource.java new file mode 100644 index 0000000000..b520705f11 --- /dev/null +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ParallelResource.java @@ -0,0 +1,49 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.examples; + +import jakarta.inject.Inject; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; + +/** + * REST endpoint that triggers the parallel creation workflow. + * + *

Runs {@link StoryCreator} (a nested {@code @SequenceAgent}) and {@link ResearchWriter} + * in parallel via a {@code ParallelOrchestrationWorkflow} Dapr Workflow. + * + *

Example usage: + *

+ * curl "http://localhost:8080/parallel?topic=dragons&country=France&style=comedy"
+ * 
+ */ +@Path("/parallel") +public class ParallelResource { + + @Inject + ParallelCreator parallelCreator; + + @GET + @Produces(MediaType.APPLICATION_JSON) + public ParallelStatus create( + @QueryParam("topic") @DefaultValue("dragons and wizards") String topic, + @QueryParam("country") @DefaultValue("France") String country, + @QueryParam("style") @DefaultValue("fantasy") String style) { + return parallelCreator.create(topic, country, style); + } +} diff --git a/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ParallelStatus.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ParallelStatus.java new file mode 100644 index 0000000000..e01a5e0f19 --- /dev/null +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ParallelStatus.java @@ -0,0 +1,17 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.examples; + +public record ParallelStatus(String status, String story, String summary) { +} diff --git a/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ResearchAndWrite.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ResearchAndWrite.java new file mode 100644 index 0000000000..ad57c28c11 --- /dev/null +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ResearchAndWrite.java @@ -0,0 +1,45 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.examples; + +import dev.langchain4j.agentic.declarative.Output; +import dev.langchain4j.agentic.declarative.ParallelAgent; +import dev.langchain4j.service.V; + +/** + * Parallel composite with an {@code @Output} combiner: runs {@link CreativeWriter} (writes + * {@code story}) and {@link ResearchWriter} (writes {@code summary}) concurrently, then combines + * their scope outputs into the result. + * + *

This exercises the {@code @Output} path: the {@code durable-parallel} workflow runs both + * react-agent children, then invokes {@link #combine} to produce the result. + */ +public interface ResearchAndWrite { + + @ParallelAgent(name = "research-and-write-agent", outputKey = "combined", + subAgents = { CreativeWriter.class, ResearchWriter.class }) + String run(@V("topic") String topic, @V("country") String country); + + /** + * Combines the two sub-agent outputs (matched from scope by parameter name) into the result. + * + * @param story the creative-writer output (scope key {@code story}) + * @param summary the research output (scope key {@code summary}) + * @return the combined text + */ + @Output + static String combine(String story, String summary) { + return "STORY: " + story + "\nRESEARCH: " + summary; + } +} diff --git a/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ResearchResource.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ResearchResource.java new file mode 100644 index 0000000000..4664fe73e2 --- /dev/null +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ResearchResource.java @@ -0,0 +1,53 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.examples; + +import jakarta.inject.Inject; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; + +/** + * REST endpoint that triggers a research workflow with tool calls routed through + * Dapr Workflow Activities. + * + *

Each request: + *

    + *
  1. Runs the {@link ResearchWriter} agent's ReAct loop as a {@code react-agent} workflow.
  2. + *
  3. Each LLM tool call ({@code getPopulation} / {@code getCapital}) is executed as an + * {@code agent-tool} Dapr Workflow Activity.
  4. + *
+ * + *

Example usage: + *

+ * curl "http://localhost:8080/research?country=France"
+ * curl "http://localhost:8080/research?country=Germany"
+ * 
+ */ +@Path("/research") +public class ResearchResource { + + @Inject + ResearchWriter researchWriter; + + @GET + @Produces(MediaType.TEXT_PLAIN) + public String research( + @QueryParam("country") @DefaultValue("France") String country) { + return researchWriter.research(country); + } +} diff --git a/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ResearchTools.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ResearchTools.java new file mode 100644 index 0000000000..10381d4494 --- /dev/null +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ResearchTools.java @@ -0,0 +1,68 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.examples; + +import dev.langchain4j.agent.tool.Tool; +import jakarta.enterprise.context.ApplicationScoped; + +/** + * CDI bean providing research tools for the {@link ResearchWriter} agent. + * + *

Because the agent's ReAct loop runs as a Dapr Workflow ({@code react-agent}), every tool + * call the model requests is dispatched as an {@code agent-tool} Dapr Workflow Activity. + * + *

This means: + *

    + *
  • Each tool call is recorded in the Dapr Workflow history.
  • + *
  • If the process crashes during a tool call, Dapr retries the activity automatically.
  • + *
  • No code changes are needed here — the routing is applied automatically.
  • + *
+ */ +@ApplicationScoped +public class ResearchTools { + + /** + * Looks up real-time population data for a given country. + * + * @param country the country to look up + * @return population data string + */ + @Tool("Looks up real-time population data for a given country") + public String getPopulation(String country) { + // In a real implementation this would call an external API. + // Here we return a stub so the example runs without network access. + return switch (country.toLowerCase()) { + case "france" -> "France has approximately 68 million inhabitants (2024)."; + case "germany" -> "Germany has approximately 84 million inhabitants (2024)."; + case "japan" -> "Japan has approximately 124 million inhabitants (2024)."; + default -> country + " population data is not available in this demo."; + }; + } + + /** + * Returns the official capital city of a given country. + * + * @param country the country to look up + * @return capital city string + */ + @Tool("Returns the official capital city of a given country") + public String getCapital(String country) { + return switch (country.toLowerCase()) { + case "france" -> "The capital of France is Paris."; + case "germany" -> "The capital of Germany is Berlin."; + case "japan" -> "The capital of Japan is Tokyo."; + default -> "Capital city for " + country + " is not available in this demo."; + }; + } +} diff --git a/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ResearchWriter.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ResearchWriter.java new file mode 100644 index 0000000000..278b01f205 --- /dev/null +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ResearchWriter.java @@ -0,0 +1,46 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.examples; + +import dev.langchain4j.agentic.Agent; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; +import io.quarkiverse.langchain4j.ToolBox; + +/** + * Sub-agent that writes a short research summary about a country by calling tools. + * + *

The {@link ToolBox} annotation tells quarkus-langchain4j to make {@link ResearchTools} + * available to the LLM for this agent's method. When the LLM decides to call + * {@code getPopulation} or {@code getCapital}, the {@code quarkus-langchain4j-dapr-agentic} extension runs + * that call as an {@code agent-tool} Dapr Workflow Activity — a durable, auditable record of + * every tool invocation. + * + *

Architecture note: No changes are required in this interface to enable the durable + * routing. The {@code quarkus-langchain4j-dapr-agentic} extension replaces this agent's bean with a proxy + * that runs its ReAct loop as a Dapr Workflow ({@code react-agent}). + */ +public interface ResearchWriter { + + @ToolBox(ResearchTools.class) + @UserMessage(""" + You are a research assistant. + Write a concise 2-sentence summary about the country {{country}} + using the available tools to fetch accurate data. + Return only the summary. + """) + @Agent(name = "research-location-agent", + description = "Researches and summarises facts about a country", outputKey = "summary") + String research(@V("country") String country); +} diff --git a/quarkus/examples/src/main/java/io/dapr/quarkus/examples/StoryCreator.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/StoryCreator.java new file mode 100644 index 0000000000..ba9dd6139c --- /dev/null +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/StoryCreator.java @@ -0,0 +1,32 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.examples; + +import dev.langchain4j.agentic.declarative.SequenceAgent; +import dev.langchain4j.service.V; + +/** + * Composite agent that orchestrates {@link CreativeWriter} and {@link StyleEditor} + * in sequence, backed by a Dapr Workflow. + * + *

{@code @SequenceAgent} runs as a {@code durable-sequence} Dapr Workflow that invokes each + * sub-agent in order as a child {@code react-agent} workflow. + */ +public interface StoryCreator { + + @SequenceAgent(name = "story-creator-agent", + outputKey = "story", + subAgents = { CreativeWriter.class, StyleEditor.class }) + String write(@V("topic") String topic, @V("style") String style); +} diff --git a/quarkus/examples/src/main/java/io/dapr/quarkus/examples/StoryResource.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/StoryResource.java new file mode 100644 index 0000000000..00aa063462 --- /dev/null +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/StoryResource.java @@ -0,0 +1,45 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.examples; + +import jakarta.inject.Inject; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; + +/** + * REST endpoint that triggers the sequential story creation workflow. + * + *

Example usage: + *

+ * curl "http://localhost:8080/story?topic=dragons&style=comedy"
+ * 
+ */ +@Path("/story") +public class StoryResource { + + @Inject + StoryCreator storyCreator; + + @GET + @Produces(MediaType.TEXT_PLAIN) + public String createStory( + @QueryParam("topic") @DefaultValue("dragons and wizards") String topic, + @QueryParam("style") @DefaultValue("fantasy") String style) { + return storyCreator.write(topic, style); + } +} diff --git a/quarkus/examples/src/main/java/io/dapr/quarkus/examples/StoryRouter.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/StoryRouter.java new file mode 100644 index 0000000000..2cd4cc4f7f --- /dev/null +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/StoryRouter.java @@ -0,0 +1,56 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.examples; + +import dev.langchain4j.agentic.declarative.ActivationCondition; +import dev.langchain4j.agentic.declarative.ConditionalAgent; +import dev.langchain4j.service.V; + +/** + * Conditional composite: routes a {@code topic} to {@link CreativeWriter} (short topics) or + * {@link SummaryWriter} (longer topics) via {@code @ActivationCondition} predicates. + * + *

Both branches consume the same {@code topic} input (so the composite validates), and the + * routing key is that same input. This runs as the {@code durable-conditional} workflow, which + * evaluates the pure static conditions and runs the matching sub-agent as a {@code react-agent} + * child. + */ +public interface StoryRouter { + + @ConditionalAgent(name = "story-router-agent", outputKey = "story", + subAgents = { CreativeWriter.class, SummaryWriter.class }) + String route(@V("topic") String topic); + + /** + * Routes short topics to the creative writer. + * + * @param topic the topic + * @return true when the topic is short + */ + @ActivationCondition(CreativeWriter.class) + static boolean creativeForShortTopic(@V("topic") String topic) { + return topic != null && topic.length() < 12; + } + + /** + * Routes longer topics to the summary writer. + * + * @param topic the topic + * @return true when the topic is long + */ + @ActivationCondition(SummaryWriter.class) + static boolean summaryForLongTopic(@V("topic") String topic) { + return topic == null || topic.length() >= 12; + } +} diff --git a/quarkus/examples/src/main/java/io/dapr/quarkus/examples/StyleEditor.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/StyleEditor.java new file mode 100644 index 0000000000..3d1e67db95 --- /dev/null +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/StyleEditor.java @@ -0,0 +1,34 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.examples; + +import dev.langchain4j.agentic.Agent; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; + +/** + * Sub-agent that edits a story to improve its writing style. + */ +public interface StyleEditor { + + @UserMessage(""" + You are a style editor. + Review the following story and improve its style to match the requested style: {{style}}. + Return only the improved story and nothing else. + Story: {{story}} + """) + @Agent(name = "style-editor-agent", description = "Edit a story to improve its writing style", + outputKey = "story") + String editStory(@V("story") String story, @V("style") String style); +} diff --git a/quarkus/examples/src/main/java/io/dapr/quarkus/examples/SummaryWriter.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/SummaryWriter.java new file mode 100644 index 0000000000..ab941c0ed5 --- /dev/null +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/SummaryWriter.java @@ -0,0 +1,29 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.examples; + +import dev.langchain4j.agentic.Agent; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; + +/** + * Sub-agent that writes a one-sentence summary of a topic. Used as the second branch of + * {@link StoryRouter} so both branches consume the same {@code topic} input. + */ +public interface SummaryWriter { + + @UserMessage("Summarize the topic {{topic}} in a single sentence. Return only the sentence.") + @Agent(name = "summary-writer-agent", description = "Summarize a topic", outputKey = "story") + String summarize(@V("topic") String topic); +} diff --git a/quarkus/examples/src/main/resources/application.properties b/quarkus/examples/src/main/resources/application.properties new file mode 100644 index 0000000000..8ceffbc59c --- /dev/null +++ b/quarkus/examples/src/main/resources/application.properties @@ -0,0 +1,26 @@ +# Dapr Dev Services (automatically starts Dapr sidecar, placement, scheduler, state store) +quarkus.dapr.devservices.enabled=true +quarkus.dapr.workflow.enabled=true +# OpenAI configuration +# Set your API key via environment variable: export OPENAI_API_KEY=sk-... +quarkus.langchain4j.openai.api-key=${OPENAI_API_KEY:demo} +quarkus.langchain4j.openai.chat-model.model-name=gpt-4o-mini + +quarkus.log.category."io.dapr.quarkus.agents.registry".level=DEBUG + +# OpenTelemetry Configuration +quarkus.otel.propagators=tracecontext,baggage + +# LangChain4j Tracing - gen_ai spans +quarkus.langchain4j.tracing.include-prompt=true +quarkus.langchain4j.tracing.include-completion=true +quarkus.langchain4j.tracing.include-tool-arguments=true +quarkus.langchain4j.tracing.include-tool-result=true + +# Dapr Workflows +quarkus.log.category."io.quarkiverse.dapr.workflows".level=DEBUG + + +#dapr.agents.statestore=agent-registry +#dapr.agents.team=default +#dapr.appid=agentic-example diff --git a/quarkus/examples/src/test/java/io/dapr/quarkus/examples/DaprWorkflowClientTest.java b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/DaprWorkflowClientTest.java new file mode 100644 index 0000000000..2f527db893 --- /dev/null +++ b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/DaprWorkflowClientTest.java @@ -0,0 +1,28 @@ +package io.dapr.quarkus.examples; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import io.dapr.workflows.client.DaprWorkflowClient; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; + +/** + * Verifies that the Dapr infrastructure is properly started by dev services. + * Tests that the DaprWorkflowClient CDI bean is available and connected + * to the Dapr sidecar backed by PostgreSQL state store. + */ +@QuarkusTest +@ExtendWith(DockerAvailableCondition.class) +class DaprWorkflowClientTest { + + @Inject + DaprWorkflowClient workflowClient; + + @Test + void daprWorkflowClientShouldBeAvailable() { + assertNotNull(workflowClient, "DaprWorkflowClient should be injected by Dapr dev services"); + } +} diff --git a/quarkus/examples/src/test/java/io/dapr/quarkus/examples/DockerAvailableCondition.java b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/DockerAvailableCondition.java new file mode 100644 index 0000000000..db8c786ede --- /dev/null +++ b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/DockerAvailableCondition.java @@ -0,0 +1,48 @@ +package io.dapr.quarkus.examples; + +import java.io.File; + +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExecutionCondition; +import org.junit.jupiter.api.extension.ExtensionContext; + +/** + * JUnit 5 {@link ExecutionCondition} that disables tests when Docker is not available. + * This prevents Dapr Testcontainers-based integration tests from failing in + * environments without Docker (e.g., CI without Docker-in-Docker). + *

+ * Checks for Docker by looking for the Docker socket file or running + * {@code docker info}. Usage: annotate test classes with + * {@code @ExtendWith(DockerAvailableCondition.class)}. + */ +public class DockerAvailableCondition implements ExecutionCondition { + + @Override + public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { + // Check common Docker socket locations + if (new File("/var/run/docker.sock").exists()) { + return ConditionEvaluationResult.enabled("Docker socket found at /var/run/docker.sock"); + } + + // Try Docker Desktop on macOS + String home = System.getProperty("user.home"); + if (home != null && new File(home + "/.docker/run/docker.sock").exists()) { + return ConditionEvaluationResult.enabled("Docker socket found at ~/.docker/run/docker.sock"); + } + + // Fallback: try running 'docker info' + try { + Process process = new ProcessBuilder("docker", "info") + .redirectErrorStream(true) + .start(); + int exitCode = process.waitFor(); + if (exitCode == 0) { + return ConditionEvaluationResult.enabled("Docker is available (docker info succeeded)"); + } + } catch (Exception e) { + // docker command not found or failed + } + + return ConditionEvaluationResult.disabled("Docker is not available, skipping integration test"); + } +} diff --git a/quarkus/examples/src/test/java/io/dapr/quarkus/examples/DurableAgentResourceTest.java b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/DurableAgentResourceTest.java new file mode 100644 index 0000000000..b7e9398790 --- /dev/null +++ b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/DurableAgentResourceTest.java @@ -0,0 +1,35 @@ +package io.dapr.quarkus.examples; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import io.quarkus.test.junit.QuarkusTest; + +/** + * Milestone-1 proof for the control-inversion approach: the agent's ReAct loop runs as a + * durable Dapr Workflow ({@code react-agent}), driven by {@link DurableAgentResource}, with + * the LLM call executed via the {@code agent-llm} activity. + *

+ * Uses {@link MockChatModel} (no tool calls), so the workflow makes one {@code agent-llm} + * call and completes. Requires Docker for Dapr dev services. + */ +@QuarkusTest +@ExtendWith(DockerAvailableCondition.class) +class DurableAgentResourceTest { + + @Test + void durableAgentCompletesViaWorkflow() { + given() + .queryParam("topic", "dragons") + .when() + .get("/durable") + .then() + .statusCode(200) + .body(notNullValue()) + .body(not("")); + } +} diff --git a/quarkus/examples/src/test/java/io/dapr/quarkus/examples/DurableEntryPointTest.java b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/DurableEntryPointTest.java new file mode 100644 index 0000000000..33b6e5d35b --- /dev/null +++ b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/DurableEntryPointTest.java @@ -0,0 +1,95 @@ +package io.dapr.quarkus.examples; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import io.quarkus.test.junit.QuarkusTest; + +/** + * Drop-in proof for the control-inversion entry point (uniform): plain, unchanged agent + * interfaces are injected and called normally, but their AiServices-built bean is replaced by a + * durable-workflow proxy — a leaf {@link CreativeWriter} runs as {@code react-agent}, and a + * composite {@link StoryCreator} ({@code @SequenceAgent}) runs as {@code durable-sequence} over + * react-agent children. No code changes to the agents. + *

+ * Uses {@link MockChatModel}; a non-blank result means the workflow ran and completed. + */ +@QuarkusTest +@ExtendWith(DockerAvailableCondition.class) +class DurableEntryPointTest { + + @Inject + CreativeWriter creativeWriter; + + @Inject + StoryCreator storyCreator; + + @Inject + StoryRouter storyRouter; + + @Inject + ResearchAndWrite researchAndWrite; + + @Inject + LoopWriter loopWriter; + + @Inject + ParallelCreator parallelCreator; + + @Test + void leafAgentRunsAsReactAgentWorkflow() { + String story = creativeWriter.generateStory("dragons"); + assertNotNull(story); + assertFalse(story.isBlank(), "expected the durable react-agent workflow to return a story"); + } + + @Test + void compositeRunsAsDurableSequenceWorkflow() { + String story = storyCreator.write("dragons", "comedy"); + assertNotNull(story); + assertFalse(story.isBlank(), "expected the durable-sequence workflow to return a story"); + } + + @Test + void conditionalRunsAsDurableConditionalWorkflow() { + String story = storyRouter.route("dragons"); + assertNotNull(story); + assertFalse(story.isBlank(), "expected the durable-conditional workflow to return a story"); + } + + @Test + void parallelWithOutputCombinerProducesCombinedResult() { + String combined = researchAndWrite.run("dragons", "France"); + assertNotNull(combined); + // The @Output combiner ran over both sub-agents' scope outputs (not an empty outputKey). + assertTrue(combined.contains("STORY:") && combined.contains("RESEARCH:"), + "expected the @Output combiner to merge both sub-agent outputs, got: " + combined); + } + + @Test + void loopRunsAsDurableLoopWorkflow() { + String story = loopWriter.write("dragons", "comedy"); + assertNotNull(story); + assertFalse(story.isBlank(), "expected the durable-loop workflow to return a story"); + } + + @Test + void nestedCompositeRunsAsDurableTreeWithStructuredOutput() { + // ParallelCreator (@ParallelAgent) nests StoryCreator (@SequenceAgent) as a sub-agent and + // returns a ParallelStatus record via @Output. This exercises recursive dispatch + // (durable-parallel -> child durable-sequence -> react-agents), scope propagation (the + // nested "story" bubbles up to the @Output combiner), and structured return coercion. + ParallelStatus status = parallelCreator.create("dragons", "France", "comedy"); + assertNotNull(status); + assertEquals("OK", status.status()); + assertNotNull(status.story(), "nested @SequenceAgent output should propagate up"); + assertNotNull(status.summary()); + } +} diff --git a/quarkus/examples/src/test/java/io/dapr/quarkus/examples/DurableToolAgentResourceTest.java b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/DurableToolAgentResourceTest.java new file mode 100644 index 0000000000..210c2472b9 --- /dev/null +++ b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/DurableToolAgentResourceTest.java @@ -0,0 +1,36 @@ +package io.dapr.quarkus.examples; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.containsString; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; + +/** + * Tool-path proof for the control-inversion approach: a tool-using agent + * ({@code research-location-agent}) runs as the {@code react-agent} workflow, the model + * requests a tool, the {@code agent-tool} activity executes the real {@link ResearchTools} + * method, and its result flows back into the loop to produce the final answer. + *

+ * {@link ToolCallingMockChatModel} returns one tool call then echoes the tool result, so the + * response containing "Paris" proves {@code getCapital} executed via the activity. + */ +@QuarkusTest +@TestProfile(ToolCallingChatModelProfile.class) +@ExtendWith(DockerAvailableCondition.class) +class DurableToolAgentResourceTest { + + @Test + void durableToolAgentExecutesToolViaActivity() { + given() + .queryParam("country", "France") + .when() + .get("/durable/research") + .then() + .statusCode(200) + .body(containsString("Paris")); + } +} diff --git a/quarkus/examples/src/test/java/io/dapr/quarkus/examples/LoopResourceTest.java b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/LoopResourceTest.java new file mode 100644 index 0000000000..9bb693b5d0 --- /dev/null +++ b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/LoopResourceTest.java @@ -0,0 +1,47 @@ +package io.dapr.quarkus.examples; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.config.HttpClientConfig; +import io.restassured.config.RestAssuredConfig; + +/** + * HTTP smoke test for the {@code /loop} endpoint. + *

+ * {@link LoopWriter} ({@code @LoopAgent}) runs {@link CreativeWriter} and {@link StyleEditor} + * as a {@code durable-loop} workflow; a non-empty 200 response means the loop ran end-to-end. + * The durable-loop semantics are covered in {@link DurableEntryPointTest}. + *

+ * Requires Docker for Dapr dev services. Uses {@link MockChatModel} instead of a real LLM. + */ +@QuarkusTest +@ExtendWith(DockerAvailableCondition.class) +class LoopResourceTest { + + /** + * Bound the HTTP wait: if the loop stalls, the request would otherwise hang forever. + */ + private static final RestAssuredConfig BOUNDED = RestAssuredConfig.config() + .httpClient(HttpClientConfig.httpClientConfig() + .setParam("http.socket.timeout", 120_000)); + + @Test + void testLoopEndpointReturnsResponse() { + given() + .config(BOUNDED) + .queryParam("topic", "dragons") + .queryParam("style", "comedy") + .when() + .get("/loop") + .then() + .statusCode(200) + .body(notNullValue()) + .body(not("")); + } +} diff --git a/quarkus/examples/src/test/java/io/dapr/quarkus/examples/MockChatModel.java b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/MockChatModel.java new file mode 100644 index 0000000000..49c8d95024 --- /dev/null +++ b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/MockChatModel.java @@ -0,0 +1,33 @@ +package io.dapr.quarkus.examples; + +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.output.FinishReason; +import dev.langchain4j.model.output.TokenUsage; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Alternative; + +/** + * Mock ChatModel that returns predictable responses for integration testing. + * {@code @Alternative @Priority(100)} so it wins over the configured provider's + * {@link ChatModel} bean, which the durable {@code agent-llm} activity resolves via Arc. + */ +@Alternative +@Priority(100) +@ApplicationScoped +public class MockChatModel implements ChatModel { + + @Override + public ChatResponse doChat(ChatRequest request) { + return ChatResponse.builder() + .aiMessage(AiMessage.from("Once upon a time, a brave dragon befriended a wizard. " + + "Together they embarked on an epic adventure across enchanted lands. " + + "Their story became legend, told for generations.")) + .tokenUsage(new TokenUsage(10, 30)) + .finishReason(FinishReason.STOP) + .build(); + } +} diff --git a/quarkus/examples/src/test/java/io/dapr/quarkus/examples/ParallelResourceTest.java b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/ParallelResourceTest.java new file mode 100644 index 0000000000..09a41d9e78 --- /dev/null +++ b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/ParallelResourceTest.java @@ -0,0 +1,51 @@ +package io.dapr.quarkus.examples; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import io.quarkus.test.junit.QuarkusTest; + +/** + * Integration test for the parallel creation workflow. + *

+ * {@link ParallelCreator} runs {@link StoryCreator} (a nested {@code @SequenceAgent}) + * and {@link ResearchWriter} in parallel, verifying that nested composite agents work + * correctly with Dapr Workflows. + *

+ * Requires Docker for Dapr dev services. Uses {@link MockChatModel} instead of a real LLM. + */ +@QuarkusTest +@ExtendWith(DockerAvailableCondition.class) +class ParallelResourceTest { + + @Test + void testParallelEndpointReturnsResponse() { + given() + .queryParam("topic", "dragons") + .queryParam("country", "France") + .queryParam("style", "comedy") + .when() + .get("/parallel") + .then() + .statusCode(200) + .body("status", equalTo("OK")) + .body("story", notNullValue()) + .body("summary", notNullValue()); + } + + @Test + void testParallelEndpointWithDefaultParams() { + given() + .when() + .get("/parallel") + .then() + .statusCode(200) + .body("status", equalTo("OK")) + .body("story", notNullValue()) + .body("summary", notNullValue()); + } +} \ No newline at end of file diff --git a/quarkus/examples/src/test/java/io/dapr/quarkus/examples/StoryResourceTest.java b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/StoryResourceTest.java new file mode 100644 index 0000000000..5de3a60bd4 --- /dev/null +++ b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/StoryResourceTest.java @@ -0,0 +1,59 @@ +package io.dapr.quarkus.examples; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.notNullValue; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import io.quarkus.test.junit.QuarkusTest; + +/** + * Integration test for the story creation workflow. + *

+ * Requires Docker for Dapr dev services (starts daprd, placement, scheduler, + * PostgreSQL state store, and dashboard containers via Testcontainers). + * Uses {@link MockChatModel} instead of a real LLM. + */ +@QuarkusTest +@ExtendWith(DockerAvailableCondition.class) +class StoryResourceTest { + + @Test + void testStoryEndpointReturnsResponse() { + given() + .queryParam("topic", "dragons") + .queryParam("style", "comedy") + .when() + .get("/story") + .then() + .statusCode(200) + .body(notNullValue()); + } + + @Test + void testStoryEndpointWithDefaultParams() { + given() + .when() + .get("/story") + .then() + .statusCode(200) + .body(notNullValue()); + } + + @Test + void testStoryEndpointResponseContainsContent() { + String body = given() + .queryParam("topic", "space exploration") + .queryParam("style", "sci-fi") + .when() + .get("/story") + .then() + .statusCode(200) + .extract() + .asString(); + + // The mock model always returns the same text; verify it's non-empty + assert !body.isBlank() : "Story response should not be blank"; + } +} diff --git a/quarkus/examples/src/test/java/io/dapr/quarkus/examples/ToolCallingChatModelProfile.java b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/ToolCallingChatModelProfile.java new file mode 100644 index 0000000000..7378495e40 --- /dev/null +++ b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/ToolCallingChatModelProfile.java @@ -0,0 +1,27 @@ +package io.dapr.quarkus.examples; + +import java.util.Map; +import java.util.Set; + +import io.quarkus.test.junit.QuarkusTestProfile; + +/** + * Test profile for the durable tool-path test: swaps the plain {@link MockChatModel} for the + * tool-driving {@link ToolCallingMockChatModel}. + *

+ * Excludes {@link MockChatModel} (so there is no ambiguity) and enables the tool-calling + * alternative. Isolated to tests annotated with this profile — the other examples tests keep + * the plain mock. + */ +public class ToolCallingChatModelProfile implements QuarkusTestProfile { + + @Override + public Set> getEnabledAlternatives() { + return Set.of(ToolCallingMockChatModel.class); + } + + @Override + public Map getConfigOverrides() { + return Map.of("quarkus.arc.exclude-types", "io.dapr.quarkus.examples.MockChatModel"); + } +} diff --git a/quarkus/examples/src/test/java/io/dapr/quarkus/examples/ToolCallingMockChatModel.java b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/ToolCallingMockChatModel.java new file mode 100644 index 0000000000..427ef69b8c --- /dev/null +++ b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/ToolCallingMockChatModel.java @@ -0,0 +1,58 @@ +package io.dapr.quarkus.examples; + +import dev.langchain4j.agent.tool.ToolExecutionRequest; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.ToolExecutionResultMessage; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.output.FinishReason; +import dev.langchain4j.model.output.TokenUsage; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Alternative; + +/** + * Stateful mock that drives one tool round-trip, for the durable tool-path test. + *

+ * First turn (tools advertised, no tool result yet): requests {@code getCapital}. + * Second turn (a tool result is present): echoes that result as the final answer — so the + * assertion can confirm the tool actually executed via the {@code agent-tool} activity and + * its output flowed back into the loop. + *

+ * Not globally enabled (no {@code @Priority}); activated only by {@code ToolCallingChatModelProfile}. + */ +@Alternative +@ApplicationScoped +public class ToolCallingMockChatModel implements ChatModel { + + @Override + public ChatResponse doChat(ChatRequest request) { + boolean hasTools = request.toolSpecifications() != null && !request.toolSpecifications().isEmpty(); + boolean toolAlreadyRan = request.messages().stream() + .anyMatch(m -> m instanceof ToolExecutionResultMessage); + + if (hasTools && !toolAlreadyRan) { + return ChatResponse.builder() + .aiMessage(AiMessage.from(ToolExecutionRequest.builder() + .id("call-capital") + .name("getCapital") + .arguments("{\"country\":\"France\"}") + .build())) + .tokenUsage(new TokenUsage(5, 5)) + .finishReason(FinishReason.TOOL_EXECUTION) + .build(); + } + + String toolText = request.messages().stream() + .filter(m -> m instanceof ToolExecutionResultMessage) + .map(m -> ((ToolExecutionResultMessage) m).text()) + .reduce((first, second) -> second) + .orElse("no tool result"); + return ChatResponse.builder() + .aiMessage(AiMessage.from("Summary: " + toolText)) + .tokenUsage(new TokenUsage(5, 10)) + .finishReason(FinishReason.STOP) + .build(); + } +} diff --git a/quarkus/examples/src/test/resources/application.properties b/quarkus/examples/src/test/resources/application.properties new file mode 100644 index 0000000000..00e0596475 --- /dev/null +++ b/quarkus/examples/src/test/resources/application.properties @@ -0,0 +1,16 @@ +# Dapr Dev Services with PostgreSQL state store (dashboard enabled = uses PostgreSQL) +quarkus.dapr.devservices.enabled=true +quarkus.dapr.devservices.dashboard.enabled=true +quarkus.dapr.workflow.enabled=true +# Match the Dapr runtime pinned by testcontainers-dapr 1.18.0 (canonical +# daprd/placement/scheduler images; Testcontainers rejects other tags) +quarkus.dapr.devservices.daprd-image=daprio/daprd:1.18.0 + +# Load gRPC/protobuf/Jackson parent-first: the Dapr SDK uses them across the +# @QuarkusTest classloader boundary (dev services + workflow runtime), otherwise +# startup fails with LinkageError (duplicate io.grpc.Channel / protobuf Message). +quarkus.class-loading.parent-first-artifacts=io.grpc:grpc-api,io.grpc:grpc-core,io.grpc:grpc-stub,io.grpc:grpc-protobuf,io.grpc:grpc-protobuf-lite,io.grpc:grpc-netty-shaded,io.grpc:grpc-context,io.grpc:grpc-util,com.fasterxml.jackson.core:jackson-databind,com.fasterxml.jackson.core:jackson-core,com.fasterxml.jackson.core:jackson-annotations,com.fasterxml.jackson.datatype:jackson-datatype-jsr310,com.google.protobuf:protobuf-java,com.google.protobuf:protobuf-java-util,com.google.api.grpc:proto-google-common-protos + +# Dummy OpenAI key (MockChatModel overrides the real provider in tests) +quarkus.langchain4j.openai.api-key=test-key +quarkus.langchain4j.openai.chat-model.model-name=gpt-4o-mini diff --git a/quarkus/pom.xml b/quarkus/pom.xml new file mode 100644 index 0000000000..15a61b8efb --- /dev/null +++ b/quarkus/pom.xml @@ -0,0 +1,106 @@ + + + 4.0.0 + + + io.dapr + dapr-sdk-parent + 1.19.0-SNAPSHOT + ../pom.xml + + io.dapr.quarkus + dapr-quarkus-agentic-parent + 0.1.0-SNAPSHOT + + pom + Dapr Quarkus Agentic - Parent + + + quarkus-langchain4j-dapr-agentic + quarkus-langchain4j-dapr-llm + quarkus-langchain4j-dapr-registry + examples + + + + 17 + 17 + UTF-8 + 3.31.2 + 2.5.0 + 1.7.1 + + 1.18.0 + + + + + + io.quarkus + quarkus-bom + ${quarkus.version} + pom + import + + + + io.dapr + dapr-sdk-workflows + ${dapr.sdk.version} + + + + commons-io + commons-io + 2.20.0 + + + + org.testcontainers + testcontainers + 2.0.3 + + + org.testcontainers + testcontainers-junit-jupiter + 2.0.3 + + + + org.junit + junit-bom + 6.0.2 + pom + import + + + org.junit.jupiter + junit-jupiter + 6.0.2 + + + org.junit.jupiter + junit-jupiter-api + 6.0.2 + + + org.junit.jupiter + junit-jupiter-engine + 6.0.2 + + + + diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/deployment/pom.xml b/quarkus/quarkus-langchain4j-dapr-agentic/deployment/pom.xml new file mode 100644 index 0000000000..5cddcd6dc7 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/deployment/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + io.dapr.quarkus + quarkus-langchain4j-dapr-agentic-parent + 0.1.0-SNAPSHOT + ../pom.xml + + + quarkus-langchain4j-dapr-agentic-deployment + Quarkus LangChain4j Dapr Agentic - Deployment + + + + io.quarkiverse.dapr + quarkus-dapr-deployment + ${quarkus-dapr.version} + + + io.quarkiverse.langchain4j + quarkus-langchain4j-agentic-deployment + ${quarkus-langchain4j.version} + + + io.quarkus + quarkus-arc-deployment + + + io.quarkus.gizmo + gizmo + + + io.dapr.quarkus + quarkus-langchain4j-dapr-agentic + ${project.version} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java b/quarkus/quarkus-langchain4j-dapr-agentic/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java new file mode 100644 index 0000000000..816d827b01 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java @@ -0,0 +1,535 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.deployment; + +import io.dapr.quarkus.langchain4j.durable.AgentMethodMeta; +import io.dapr.quarkus.langchain4j.durable.ConditionalBranch; +import io.dapr.quarkus.langchain4j.durable.DurableAgentProxyRecorder; +import io.dapr.quarkus.langchain4j.durable.OutputCombiner; +import io.dapr.quarkus.langchain4j.workflow.DaprWorkflowRuntimeRecorder; +import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.CombinedIndexBuildItem; +import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.IndexDependencyBuildItem; +import io.quarkus.deployment.builditem.nativeimage.NativeImageProxyDefinitionBuildItem; +import io.quarkus.runtime.RuntimeValue; +import jakarta.enterprise.context.ApplicationScoped; +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTarget; +import org.jboss.jandex.AnnotationValue; +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.DotName; +import org.jboss.jandex.IndexView; +import org.jboss.jandex.MethodInfo; +import org.jboss.jandex.Type; +import org.jboss.logging.Logger; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Quarkus deployment processor for the Dapr Agentic extension (control-inversion engine). + * + *

Every declarative LangChain4j agent runs as a Dapr Workflow: a leaf {@code @Agent}'s + * ReAct loop runs as {@code react-agent}; composites ({@code @SequenceAgent} / + * {@code @ParallelAgent} / {@code @LoopAgent} / {@code @ConditionalAgent}) run as + * {@code durable-sequence} / {@code durable-parallel} / {@code durable-loop} / + * {@code durable-conditional}, calling their children directly. The only non-deterministic steps — + * the model call and each tool call — are the {@code agent-llm} and {@code agent-tool} activities, + * so all agent state lives in workflow history (per-call crash recovery, horizontal scale, and + * observability come for free). + * + *

{@code DaprWorkflowProcessor.searchWorkflows()} uses {@code ApplicationIndexBuildItem} which + * only indexes application classes — extension runtime JARs are invisible to it. We fix this by: + *

    + *
  1. Producing an {@link IndexDependencyBuildItem} so our runtime JAR is indexed into the + * {@link CombinedIndexBuildItem} (and visible to Arc for CDI bean discovery).
  2. + *
  3. Registering our workflows and activities with the Dapr workflow runtime + * ({@link #setupWorkflowRuntime}) and producing {@link AdditionalBeanBuildItem}s so Arc + * discovers them as CDI beans.
  4. + *
  5. Replacing each agent interface's AiServices-built synthetic bean with a durable-workflow + * proxy ({@link #registerDurableAgentBeans}), so unchanged user {@code @Agent} interfaces + * transparently run as workflows.
  6. + *
+ */ +public class DaprAgenticProcessor { + + private static final Logger LOG = Logger.getLogger(DaprAgenticProcessor.class); + + private static final String FEATURE = "dapr-agentic"; + + /** + * LangChain4j {@code @Agent} annotation (on AiService interface methods). + */ + private static final DotName AGENT_ANNOTATION = + DotName.createSimple("dev.langchain4j.agentic.Agent"); + + /** + * LangChain4j {@code @UserMessage} annotation. + */ + private static final DotName USER_MESSAGE_ANNOTATION = + DotName.createSimple("dev.langchain4j.service.UserMessage"); + + /** + * LangChain4j {@code @SystemMessage} annotation. + */ + private static final DotName SYSTEM_MESSAGE_ANNOTATION = + DotName.createSimple("dev.langchain4j.service.SystemMessage"); + + /** + * LangChain4j {@code @V} annotation — names a method parameter for {@code {{var}}} binding. + */ + private static final DotName V_ANNOTATION = + DotName.createSimple("dev.langchain4j.service.V"); + + /** + * {@code @WorkflowMetadata} annotation for custom workflow registration names. + */ + private static final DotName WORKFLOW_METADATA_DOTNAME = DotName.createSimple( + "io.quarkiverse.dapr.workflows.WorkflowMetadata"); + + /** + * {@code @ActivityMetadata} annotation for custom activity registration names. + */ + private static final DotName ACTIVITY_METADATA_DOTNAME = DotName.createSimple( + "io.quarkiverse.dapr.workflows.ActivityMetadata"); + + // Composite agent annotations. + private static final DotName SEQUENCE_AGENT_ANNOTATION = + DotName.createSimple("dev.langchain4j.agentic.declarative.SequenceAgent"); + private static final DotName PARALLEL_AGENT_ANNOTATION = + DotName.createSimple("dev.langchain4j.agentic.declarative.ParallelAgent"); + private static final DotName LOOP_AGENT_ANNOTATION = + DotName.createSimple("dev.langchain4j.agentic.declarative.LoopAgent"); + private static final DotName CONDITIONAL_AGENT_ANNOTATION = + DotName.createSimple("dev.langchain4j.agentic.declarative.ConditionalAgent"); + private static final DotName ACTIVATION_CONDITION_ANNOTATION = + DotName.createSimple("dev.langchain4j.agentic.declarative.ActivationCondition"); + private static final DotName OUTPUT_ANNOTATION = + DotName.createSimple("dev.langchain4j.agentic.declarative.Output"); + + /** + * Quarkus-LangChain4j {@code @ToolBox} annotation — references tool classes for an agent. + */ + private static final DotName TOOLBOX_ANNOTATION = + DotName.createSimple("io.quarkiverse.langchain4j.ToolBox"); + + private static final String[] WORKFLOW_CLASSES = { + // The agent's ReAct loop run AS a workflow. + "io.dapr.quarkus.langchain4j.durable.ReActAgentWorkflow", + // Composites: orchestrations calling react-agent (or nested composite) children directly. + "io.dapr.quarkus.langchain4j.durable.DurableSequenceWorkflow", + "io.dapr.quarkus.langchain4j.durable.DurableParallelWorkflow", + "io.dapr.quarkus.langchain4j.durable.DurableLoopWorkflow", + "io.dapr.quarkus.langchain4j.durable.DurableConditionalWorkflow", + }; + + private static final String[] ACTIVITY_CLASSES = { + // The durable ReAct loop's only non-deterministic steps. + "io.dapr.quarkus.langchain4j.durable.AgentLlmActivity", + "io.dapr.quarkus.langchain4j.durable.AgentToolActivity", + // Persists chat memory for @MemoryId agents at the end of a run. + "io.dapr.quarkus.langchain4j.durable.AgentMemorySaveActivity", + }; + + @BuildStep + FeatureBuildItem feature() { + return new FeatureBuildItem(FEATURE); + } + + /** + * Index our runtime JAR so its classes appear in {@link CombinedIndexBuildItem} + * and are discoverable by Arc for CDI bean creation. + */ + @BuildStep + IndexDependencyBuildItem indexRuntimeModule() { + return new IndexDependencyBuildItem("io.dapr.quarkus", "quarkus-langchain4j-dapr-agentic"); + } + + /** + * Register all workflows and activities using the Dapr Java SDK directly, + * bypassing quarkus-dapr's {@code WorkflowItemBuildItem} pipeline. + */ + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + void setupWorkflowRuntime(DaprWorkflowRuntimeRecorder recorder, + CombinedIndexBuildItem combinedIndex) { + + @SuppressWarnings("rawtypes") RuntimeValue builder = recorder.createBuilder(); + IndexView index = combinedIndex.getIndex(); + + // Register workflows (under their @WorkflowMetadata name). + for (String className : WORKFLOW_CLASSES) { + ClassInfo classInfo = index.getClassByName(DotName.createSimple(className)); + if (classInfo == null) { + continue; + } + String regName = className; + AnnotationInstance meta = classInfo.annotation(WORKFLOW_METADATA_DOTNAME); + if (meta != null) { + String metaName = stringValueOrNull(meta, "name"); + if (metaName != null) { + regName = metaName; + } + } + recorder.registerWorkflow(builder, regName, className); + } + + // Register activities (under their @ActivityMetadata name). + for (String className : ACTIVITY_CLASSES) { + ClassInfo classInfo = index.getClassByName(DotName.createSimple(className)); + if (classInfo == null) { + continue; + } + String regName = className; + AnnotationInstance meta = classInfo.annotation(ACTIVITY_METADATA_DOTNAME); + if (meta != null) { + String metaName = stringValueOrNull(meta, "name"); + if (metaName != null) { + regName = metaName; + } + } + recorder.registerActivity(builder, regName, className); + } + + // Register agent → tool-class mappings. For each @Agent method with a @ToolBox, extract the + // tool class names so the durable agent-llm/agent-tool activities can resolve the agent's + // tools (via AgentToolClassRegistry → AgentToolSpecRegistry). + for (AnnotationInstance ann : index.getAnnotations(AGENT_ANNOTATION)) { + if (ann.target().kind() != AnnotationTarget.Kind.METHOD) { + continue; + } + AnnotationValue nameValue = ann.value("name"); + if (nameValue == null || nameValue.asString().isEmpty()) { + continue; + } + String agentName = nameValue.asString(); + MethodInfo method = ann.target().asMethod(); + AnnotationInstance toolBoxAnn = method.annotation(TOOLBOX_ANNOTATION); + if (toolBoxAnn != null && toolBoxAnn.value() != null) { + Type[] toolBoxTypes = toolBoxAnn.value().asClassArray(); + List toolClassNames = new ArrayList<>(); + for (Type t : toolBoxTypes) { + toolClassNames.add(t.name().toString()); + } + recorder.registerAgentToolClasses(agentName, toolClassNames); + } + } + + recorder.startRuntime(builder); + } + + /** + * Explicitly register our Workflow, WorkflowActivity, and supporting CDI beans. + */ + @BuildStep + void registerAdditionalBeans(BuildProducer additionalBeans) { + for (String className : WORKFLOW_CLASSES) { + additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf(className)); + } + for (String className : ACTIVITY_CLASSES) { + additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf(className)); + } + // Tool registry — scans @Tool CDI beans at startup so the durable activities can invoke them. + additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf( + "io.dapr.quarkus.langchain4j.agent.recovery.ToolRegistry")); + // Tool-spec resolver used by the durable agent-llm activity to advertise an agent's tools. + additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf( + "io.dapr.quarkus.langchain4j.durable.AgentToolSpecRegistry")); + // Registers the Dapr-backed AgenticScope store (checkpointing) when enabled. + additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf( + "io.dapr.quarkus.langchain4j.scope.AgenticScopeStoreInitializer")); + } + + /** + * Drop-in entry point: replace each agent interface's AiServices-built synthetic bean (registered + * by the quarkiverse agentic processor) with an alternative synthetic bean whose instance is a + * {@code java.lang.reflect.Proxy} that runs the agent as a durable Dapr Workflow + * ({@code react-agent} for leaves, {@code durable-*} for composites). Marked {@code alternative} + * with priority so it wins over the AiServices bean, leaving the user's {@code @Agent} interfaces + * unchanged. + */ + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + void registerDurableAgentBeans(DurableAgentProxyRecorder recorder, + CombinedIndexBuildItem combinedIndex, + BuildProducer syntheticBeans, + BuildProducer nativeProxies) { + + IndexView index = combinedIndex.getIndex(); + Map> byInterface = new HashMap<>(); + + collectAgentMethods(index, AGENT_ANNOTATION, "react-agent", byInterface); + collectAgentMethods(index, SEQUENCE_AGENT_ANNOTATION, "durable-sequence", byInterface); + collectAgentMethods(index, PARALLEL_AGENT_ANNOTATION, "durable-parallel", byInterface); + collectAgentMethods(index, LOOP_AGENT_ANNOTATION, "durable-loop", byInterface); + collectAgentMethods(index, CONDITIONAL_AGENT_ANNOTATION, "durable-conditional", byInterface); + + for (Map.Entry> entry : byInterface.entrySet()) { + String interfaceName = entry.getKey().toString(); + LOG.infof("Registering durable agent bean for %s (%d method(s))", + interfaceName, entry.getValue().size()); + syntheticBeans.produce(SyntheticBeanBuildItem + .configure(entry.getKey()) + // Distinct identifier so this coexists with the quarkiverse-built synthetic bean + // (same types+qualifiers would otherwise collide); the alternative + priority then + // makes this one win at injection. + .identifier("durableAgent_" + interfaceName.replace('.', '_')) + .forceApplicationClass() + .createWith(recorder.createAgentProxy(interfaceName, entry.getValue())) + .setRuntimeInit() + .scope(ApplicationScoped.class) + .alternative(true) + .priority(100) + .done()); + // The durable bean instance is a java.lang.reflect.Proxy of the interface; register it + // so the proxy works under native image. + nativeProxies.produce(new NativeImageProxyDefinitionBuildItem(List.of(interfaceName))); + } + } + + private void collectAgentMethods(IndexView index, DotName annotation, String workflowName, + Map> byInterface) { + for (AnnotationInstance ann : index.getAnnotations(annotation)) { + if (ann.target().kind() != AnnotationTarget.Kind.METHOD) { + continue; + } + MethodInfo method = ann.target().asMethod(); + if (!method.declaringClass().isInterface()) { + continue; + } + AgentMethodMeta meta = buildAgentMethodMeta(index, method, annotation, workflowName); + byInterface + .computeIfAbsent(method.declaringClass().name(), k -> new HashMap<>()) + .put(method.name(), meta); + } + } + + private AgentMethodMeta buildAgentMethodMeta(IndexView index, MethodInfo method, + DotName annotation, String workflowName) { + List varNames = orderedParamNames(method); + + if (annotation.equals(AGENT_ANNOTATION)) { + // outputKey matters when this leaf is a sub-agent (its result is stored under it in the + // parent's state); harmless for a top-level leaf (the handler reads the workflow's text). + return new AgentMethodMeta(workflowName, extractAgentName(method), + extractAnnotationText(method, USER_MESSAGE_ANNOTATION), + extractAnnotationText(method, SYSTEM_MESSAGE_ANNOTATION), + varNames, List.of(), + stringValueOrNull(method.annotation(AGENT_ANNOTATION), "outputKey"), + 0, List.of(), null); + } + + AnnotationInstance composite = method.annotation(annotation); + String name = stringValueOrNull(composite, "name"); + if (name == null || name.isBlank()) { + name = method.declaringClass().name().withoutPackagePrefix() + "." + method.name(); + } + String outputKey = stringValueOrNull(composite, "outputKey"); + OutputCombiner combiner = resolveOutputCombiner(method.declaringClass()); + + if (annotation.equals(CONDITIONAL_AGENT_ANNOTATION)) { + return new AgentMethodMeta(workflowName, name, null, null, varNames, List.of(), outputKey, 0, + resolveConditionalBranches(index, composite, method.declaringClass()), combiner); + } + + int maxIterations = annotation.equals(LOOP_AGENT_ANNOTATION) ? intValueOrDefault(composite, 2) : 0; + return new AgentMethodMeta(workflowName, name, null, null, varNames, + resolveSubAgents(index, composite), outputKey, maxIterations, List.of(), combiner); + } + + private OutputCombiner resolveOutputCombiner(ClassInfo agentInterface) { + for (MethodInfo method : agentInterface.methods()) { + if (method.hasAnnotation(OUTPUT_ANNOTATION)) { + List paramNames = new ArrayList<>(); + for (int i = 0; i < method.parametersCount(); i++) { + paramNames.add(method.parameterName(i)); + } + return new OutputCombiner(agentInterface.name().toString(), method.name(), paramNames); + } + } + return null; + } + + private List resolveSubAgents(IndexView index, AnnotationInstance composite) { + List nodes = new ArrayList<>(); + AnnotationValue subAgentsValue = composite.value("subAgents"); + if (subAgentsValue == null) { + return nodes; + } + for (Type subType : subAgentsValue.asClassArray()) { + AgentMethodMeta node = resolveSubAgentNode(index, subType); + if (node != null) { + nodes.add(node); + } + } + return nodes; + } + + /** + * Resolves a sub-agent class to a recursive {@link AgentMethodMeta} node — a leaf {@code @Agent} + * or a nested composite ({@code @SequenceAgent}/{@code @ParallelAgent}/{@code @LoopAgent}/ + * {@code @ConditionalAgent}), recursing through {@link #buildAgentMethodMeta}. + */ + private AgentMethodMeta resolveSubAgentNode(IndexView index, Type subType) { + ClassInfo subInterface = index.getClassByName(subType.name()); + if (subInterface == null) { + return null; + } + for (MethodInfo subMethod : subInterface.methods()) { + if (subMethod.hasAnnotation(AGENT_ANNOTATION)) { + return buildAgentMethodMeta(index, subMethod, AGENT_ANNOTATION, "react-agent"); + } + if (subMethod.hasAnnotation(SEQUENCE_AGENT_ANNOTATION)) { + return buildAgentMethodMeta(index, subMethod, SEQUENCE_AGENT_ANNOTATION, "durable-sequence"); + } + if (subMethod.hasAnnotation(PARALLEL_AGENT_ANNOTATION)) { + return buildAgentMethodMeta(index, subMethod, PARALLEL_AGENT_ANNOTATION, "durable-parallel"); + } + if (subMethod.hasAnnotation(LOOP_AGENT_ANNOTATION)) { + return buildAgentMethodMeta(index, subMethod, LOOP_AGENT_ANNOTATION, "durable-loop"); + } + if (subMethod.hasAnnotation(CONDITIONAL_AGENT_ANNOTATION)) { + return buildAgentMethodMeta(index, subMethod, CONDITIONAL_AGENT_ANNOTATION, + "durable-conditional"); + } + } + return null; + } + + private List resolveConditionalBranches(IndexView index, + AnnotationInstance composite, ClassInfo router) { + List branches = new ArrayList<>(); + AnnotationValue subAgentsValue = composite.value("subAgents"); + if (subAgentsValue == null) { + return branches; + } + for (Type subType : subAgentsValue.asClassArray()) { + AgentMethodMeta node = resolveSubAgentNode(index, subType); + if (node == null) { + continue; + } + MethodInfo condition = findActivationCondition(router, subType.name()); + if (condition != null) { + branches.add(new ConditionalBranch(node, router.name().toString(), + condition.name(), orderedParamNames(condition))); + } else { + branches.add(new ConditionalBranch(node, null, null, List.of())); + } + } + return branches; + } + + private MethodInfo findActivationCondition(ClassInfo router, DotName subAgentClass) { + for (MethodInfo method : router.methods()) { + AnnotationInstance condition = method.annotation(ACTIVATION_CONDITION_ANNOTATION); + if (condition == null || condition.value() == null) { + continue; + } + for (Type guarded : condition.value().asClassArray()) { + if (guarded.name().equals(subAgentClass)) { + return method; + } + } + } + return null; + } + + private List orderedParamNames(MethodInfo method) { + Map byPosition = new HashMap<>(); + for (AnnotationInstance ann : method.annotations()) { + if (ann.name().equals(V_ANNOTATION) + && ann.target() != null + && ann.target().kind() == AnnotationTarget.Kind.METHOD_PARAMETER) { + AnnotationValue value = ann.value(); + if (value != null) { + byPosition.put((int) ann.target().asMethodParameter().position(), value.asString()); + } + } + } + List ordered = new ArrayList<>(); + for (int i = 0; i < method.parametersCount(); i++) { + ordered.add(byPosition.getOrDefault(i, "arg" + i)); + } + return ordered; + } + + private static int intValueOrDefault(AnnotationInstance annotation, int defaultValue) { + if (annotation == null) { + return defaultValue; + } + AnnotationValue value = annotation.value("maxIterations"); + return value == null ? defaultValue : value.asInt(); + } + + // ------------------------------------------------------------------------- + // Annotation metadata extraction (Jandex) + // ------------------------------------------------------------------------- + + /** + * Returns the {@code @Agent(name)} value if non-blank, otherwise falls back to + * {@code InterfaceName.methodName}. + */ + private String extractAgentName(MethodInfo method) { + AnnotationInstance agent = method.annotation(AGENT_ANNOTATION); + if (agent != null) { + AnnotationValue nameVal = agent.value("name"); + if (nameVal != null && !nameVal.asString().isBlank()) { + return nameVal.asString(); + } + } + return method.declaringClass().name().withoutPackagePrefix() + + "." + method.name(); + } + + /** + * Returns the joined text of a {@code String[] value()} annotation attribute, or + * {@code null} when the annotation is absent or its value is empty. + * + *

Handles both the single-string form ({@code @UserMessage("text")}) and the + * array form ({@code @UserMessage({"line1", "line2"})}). + */ + private String extractAnnotationText(MethodInfo method, DotName annotationName) { + AnnotationInstance annotation = method.annotation(annotationName); + if (annotation == null) { + return null; + } + AnnotationValue value = annotation.value(); // "value" is the default attribute + if (value == null) { + return null; + } + if (value.kind() == AnnotationValue.Kind.ARRAY) { + String[] parts = value.asStringArray(); + return parts.length == 0 ? null : String.join("\n", parts); + } + // single String stored directly (rare but defensively handled) + return value.asString(); + } + + private static String stringValueOrNull(AnnotationInstance annotation, String name) { + AnnotationValue value = annotation.value(name); + if (value == null) { + return null; + } + String sv = value.asString(); + return (sv == null || sv.isEmpty()) ? null : sv; + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/pom.xml b/quarkus/quarkus-langchain4j-dapr-agentic/pom.xml new file mode 100644 index 0000000000..fd6ef740bb --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + + io.dapr.quarkus + dapr-quarkus-agentic-parent + 0.1.0-SNAPSHOT + ../pom.xml + + + quarkus-langchain4j-dapr-agentic-parent + pom + Quarkus LangChain4j Dapr Agentic - Parent + + + runtime + deployment + + diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/pom.xml b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/pom.xml new file mode 100644 index 0000000000..f16f1d88f6 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/pom.xml @@ -0,0 +1,88 @@ + + + 4.0.0 + + io.dapr.quarkus + quarkus-langchain4j-dapr-agentic-parent + 0.1.0-SNAPSHOT + ../pom.xml + + + quarkus-langchain4j-dapr-agentic + Quarkus LangChain4j Dapr Agentic - Runtime + + + + io.quarkiverse.dapr + quarkus-dapr + ${quarkus-dapr.version} + + + io.dapr + dapr-sdk-workflows + + + io.quarkiverse.langchain4j + quarkus-langchain4j-agentic + ${quarkus-langchain4j.version} + + + io.quarkus + quarkus-arc + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + 5.14.2 + test + + + org.assertj + assertj-core + 3.26.3 + test + + + + + + + io.quarkus + quarkus-extension-maven-plugin + ${quarkus.version} + + + compile + + extension-descriptor + + + ${project.groupId}:${project.artifactId}-deployment:${project.version} + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/AgentToolClassRegistry.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/AgentToolClassRegistry.java new file mode 100644 index 0000000000..df6b14a15b --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/AgentToolClassRegistry.java @@ -0,0 +1,51 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.agent.recovery; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Static registry mapping agent names to their {@code @ToolBox} class names. + * Populated at build time by {@link io.dapr.quarkus.langchain4j.workflow.DaprWorkflowRuntimeRecorder}. + */ +public final class AgentToolClassRegistry { + + private static final Map> REGISTRY = new ConcurrentHashMap<>(); + + private AgentToolClassRegistry() { + } + + /** + * Registers the tool class names for a given agent. + * + * @param agentName the agent name from {@code @Agent(name=...)} + * @param toolClassNames fully-qualified class names from {@code @ToolBox} + */ + public static void register(String agentName, List toolClassNames) { + REGISTRY.put(agentName, List.copyOf(toolClassNames)); + } + + /** + * Returns the tool class names for the given agent, or an empty list if unknown. + * + * @param agentName the agent name + * @return unmodifiable list of tool class FQCNs + */ + public static List get(String agentName) { + return REGISTRY.getOrDefault(agentName, Collections.emptyList()); + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/ToolRegistry.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/ToolRegistry.java new file mode 100644 index 0000000000..7ca0fba285 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/ToolRegistry.java @@ -0,0 +1,153 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.agent.recovery; + +import dev.langchain4j.agent.tool.Tool; +import dev.langchain4j.agent.tool.ToolSpecification; +import dev.langchain4j.agent.tool.ToolSpecifications; +import io.quarkus.arc.Arc; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import org.jboss.logging.Logger; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * CDI bean that discovers all {@code @Tool}-annotated methods at startup and provides + * tool specification lookup and invocation for crash recovery. + * + *

During recovery, the LangChain4j AiServices ReAct loop is gone, so tools must be + * resolved and invoked independently. This registry provides that capability. + */ +@ApplicationScoped +public class ToolRegistry { + + private static final Logger LOG = Logger.getLogger(ToolRegistry.class); + + /** + * Entry for a discovered tool: the CDI bean class, method handle, and LangChain4j spec. + */ + public record ToolEntry(Class beanClass, Method method, ToolSpecification specification) { + } + + private final Map toolsByName = new HashMap<>(); + private final Map> toolsByClass = new HashMap<>(); + + @PostConstruct + void init() { + var beanManager = Arc.container().beanManager(); + for (var bean : beanManager.getBeans(Object.class, jakarta.enterprise.inject.Any.Literal.INSTANCE)) { + Class beanClass = bean.getBeanClass(); + boolean hasTools = false; + // getDeclaredMethods (not getMethods): matches LangChain4j's own + // ToolSpecifications.toolSpecificationsFrom(), which only generates specs + // for methods declared directly on the class — any access level. + for (Method m : beanClass.getDeclaredMethods()) { + if (m.isAnnotationPresent(Tool.class)) { + hasTools = true; + break; + } + } + if (!hasTools) { + continue; + } + + List specs; + try { + specs = ToolSpecifications.toolSpecificationsFrom(beanClass); + } catch (Exception e) { + LOG.debugf("Could not extract tool specs from %s: %s", beanClass.getName(), e.getMessage()); + continue; + } + + // Match specs to methods by name + List classEntries = new ArrayList<>(); + for (ToolSpecification spec : specs) { + Method toolMethod = findToolMethod(beanClass, spec.name()); + if (toolMethod != null) { + ToolEntry entry = new ToolEntry(beanClass, toolMethod, spec); + toolsByName.put(spec.name(), entry); + classEntries.add(entry); + } + } + if (!classEntries.isEmpty()) { + toolsByClass.put(beanClass.getName(), classEntries); + LOG.infof("ToolRegistry: registered %d tools from %s", classEntries.size(), beanClass.getSimpleName()); + } + } + LOG.infof("ToolRegistry: %d tools registered total", toolsByName.size()); + } + + /** + * Returns tool specifications for the given tool class names. + * + * @param classNames fully-qualified class names (from {@code @ToolBox}) + * @return list of tool specifications for those classes + */ + public List getToolSpecsForClasses(List classNames) { + List result = new ArrayList<>(); + for (String className : classNames) { + List entries = toolsByClass.get(className); + if (entries != null) { + for (ToolEntry entry : entries) { + result.add(entry.specification()); + } + } + } + return result; + } + + /** + * Returns all registered tool specifications. + * + * @return unmodifiable list of all tool specifications + */ + public List getAllToolSpecs() { + List result = new ArrayList<>(); + for (ToolEntry entry : toolsByName.values()) { + result.add(entry.specification()); + } + return Collections.unmodifiableList(result); + } + + /** + * Returns the ToolEntry for the given tool name, or null if not found. + * + * @param toolName the tool name to look up + * @return the ToolEntry, or null + */ + public ToolEntry getToolEntry(String toolName) { + return toolsByName.get(toolName); + } + + private Method findToolMethod(Class clazz, String toolName) { + for (Method m : clazz.getDeclaredMethods()) { + Tool toolAnn = m.getAnnotation(Tool.class); + if (toolAnn != null) { + // @Tool name defaults to method name if not specified + String name = toolAnn.name().isEmpty() ? m.getName() : toolAnn.name(); + if (name.equals(toolName)) { + m.setAccessible(true); + return m; + } + } + } + return null; + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/AgentLlmActivity.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/AgentLlmActivity.java new file mode 100644 index 0000000000..bac42cd1f9 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/AgentLlmActivity.java @@ -0,0 +1,68 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import dev.langchain4j.agent.tool.ToolSpecification; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.ChatMessageDeserializer; +import dev.langchain4j.data.message.ChatMessageSerializer; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import io.dapr.workflows.WorkflowActivity; +import io.dapr.workflows.WorkflowActivityContext; +import io.quarkiverse.dapr.workflows.ActivityMetadata; +import io.quarkus.arc.Arc; +import jakarta.enterprise.context.ApplicationScoped; +import org.jboss.logging.Logger; + +import java.util.List; + +/** + * Stateless activity that performs one model call for {@link ReActAgentWorkflow}. + * + *

It is a pure function of its {@link LlmInput} (conversation + agent name): deserialize + * the messages, resolve the agent's tool specifications, call the model, and return the full + * assistant message serialized as JSON. No in-memory run context, so it can run on any + * replica Dapr schedules it on. + * + *

The Dapr workflow runtime instantiates activities by reflection, not via CDI, so beans + * are obtained through {@link Arc} inside {@link #run} rather than {@code @Inject}. + */ +@ApplicationScoped +@ActivityMetadata(name = "agent-llm") +public class AgentLlmActivity implements WorkflowActivity { + + private static final Logger LOG = Logger.getLogger(AgentLlmActivity.class); + + @Override + public Object run(WorkflowActivityContext ctx) { + LlmInput input = ctx.getInput(LlmInput.class); + + ChatModel chatModel = Arc.container().instance(ChatModel.class).get(); + AgentToolSpecRegistry toolSpecRegistry = Arc.container().instance(AgentToolSpecRegistry.class).get(); + + List messages = ChatMessageDeserializer.messagesFromJson(input.messagesJson()); + List tools = toolSpecRegistry.specsFor(input.agentName()); + LOG.debugf("[agent-llm:%s] %d messages, %d tools", input.agentName(), messages.size(), tools.size()); + + ChatRequest.Builder request = ChatRequest.builder().messages(messages); + if (!tools.isEmpty()) { + request.toolSpecifications(tools); + } + + ChatResponse response = chatModel.chat(request.build()); + return new LlmResult(ChatMessageSerializer.messageToJson(response.aiMessage())); + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/AgentMemorySaveActivity.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/AgentMemorySaveActivity.java new file mode 100644 index 0000000000..e44b6c83cd --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/AgentMemorySaveActivity.java @@ -0,0 +1,41 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.ChatMessageDeserializer; +import io.dapr.workflows.WorkflowActivity; +import io.dapr.workflows.WorkflowActivityContext; +import io.quarkiverse.dapr.workflows.ActivityMetadata; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.List; + +/** + * Persists the conversation for a {@code @MemoryId} agent at the end of a {@link ReActAgentWorkflow} + * run. The save is an idempotent replace (see {@link DurableChatMemory}), so it is safe + * under Dapr's at-least-once activity delivery. + */ +@ApplicationScoped +@ActivityMetadata(name = "memory-save") +public class AgentMemorySaveActivity implements WorkflowActivity { + + @Override + public Object run(WorkflowActivityContext ctx) { + MemorySaveInput input = ctx.getInput(MemorySaveInput.class); + List messages = ChatMessageDeserializer.messagesFromJson(input.messagesJson()); + DurableChatMemory.save(input.memoryId(), messages); + return input.memoryId(); + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/AgentMethodMeta.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/AgentMethodMeta.java new file mode 100644 index 0000000000..ef037a426c --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/AgentMethodMeta.java @@ -0,0 +1,53 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import java.util.List; + +/** + * Build-time-extracted description of one agent method, used by + * {@link DurableAgentInvocationHandler} to start the right durable workflow. + * + *

Recorder-serializable (record + Strings/ints/Lists), so it can be passed through a + * {@code @Recorder} into the synthetic bean that replaces the AiServices-built agent. + * + *

This is a recursive node: {@code subAgents} (and {@code branches}) hold child + * {@code AgentMethodMeta}s, so a composite can be another composite's sub-agent. A leaf node has + * {@code workflowName == "react-agent"} and empty {@code subAgents}/{@code branches}. + * + * @param workflowName target workflow: {@code react-agent} (leaf) or {@code durable-sequence} + * / {@code durable-parallel} / {@code durable-loop} (composite) + * @param agentName agent name (leaf) or composite name; also used to name the run + * @param userTemplate leaf {@code @UserMessage} template, or {@code null} + * @param systemTemplate leaf {@code @SystemMessage} template, or {@code null} + * @param varNames method parameter names in order (the {@code @V} names), for + * {@code {{var}}} substitution / initial state + * @param subAgents child nodes (leaf or composite), empty for a leaf or conditional + * @param outputKey state key this node's result is stored under (in its parent's state) + * @param maxIterations loop iteration count (0 if not a loop) + * @param branches conditional branches (empty unless a conditional composite) + * @param combiner optional {@code @Output} combiner for the composite result, or {@code null} + */ +public record AgentMethodMeta( + String workflowName, + String agentName, + String userTemplate, + String systemTemplate, + List varNames, + List subAgents, + String outputKey, + int maxIterations, + List branches, + OutputCombiner combiner) { +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/AgentStatus.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/AgentStatus.java new file mode 100644 index 0000000000..aa003d3341 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/AgentStatus.java @@ -0,0 +1,24 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +/** + * Custom status published on the workflow for live observability in the Dapr dashboard. + * + * @param agentName the agent being executed + * @param messageCount number of messages accumulated so far + * @param done whether the agent produced its final answer + */ +public record AgentStatus(String agentName, int messageCount, boolean done) { +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/AgentToolActivity.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/AgentToolActivity.java new file mode 100644 index 0000000000..3c21065077 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/AgentToolActivity.java @@ -0,0 +1,81 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import dev.langchain4j.agent.tool.ToolExecutionRequest; +import dev.langchain4j.service.tool.DefaultToolExecutor; +import io.dapr.quarkus.langchain4j.agent.recovery.ToolRegistry; +import io.dapr.workflows.WorkflowActivity; +import io.dapr.workflows.WorkflowActivityContext; +import io.quarkiverse.dapr.workflows.ActivityMetadata; +import io.quarkus.arc.Arc; +import jakarta.enterprise.context.ApplicationScoped; +import org.jboss.logging.Logger; + +/** + * Stateless activity that invokes one {@code @Tool} for {@link ReActAgentWorkflow}. + * + *

Resolves the tool by name via {@link ToolRegistry} and executes it with LangChain4j's + * {@link DefaultToolExecutor} — the same binder AiServices uses — so JSON arguments are bound + * to the method signature with full fidelity ({@code @P} parameters, nested/complex types). + * Like {@link AgentLlmActivity}, it depends only on its {@link ToolInput} and the (replica-wide) + * tool registry, obtained via {@link Arc} since the workflow runtime instantiates activities by + * reflection. + * + *

Self-correction: an unknown (hallucinated) tool, or a binding/execution failure, + * returns the error text as the tool result rather than failing the workflow, so the model can + * react and retry on the next turn (LangChain4j ReAct semantics). + * + *

At-least-once: activities can be redelivered, so side-effecting tools must be + * idempotent or externally guarded. + */ +@ApplicationScoped +@ActivityMetadata(name = "agent-tool") +public class AgentToolActivity implements WorkflowActivity { + + private static final Logger LOG = Logger.getLogger(AgentToolActivity.class); + + @Override + public Object run(WorkflowActivityContext ctx) { + ToolInput input = ctx.getInput(ToolInput.class); + ToolRegistry toolRegistry = Arc.container().instance(ToolRegistry.class).get(); + ToolRegistry.ToolEntry entry = toolRegistry.getToolEntry(input.toolName()); + + // Unknown / hallucinated tool: feed an error back to the model instead of crashing the + // workflow, so the loop can self-correct. + if (entry == null) { + LOG.warnf("Model requested unknown tool '%s'", input.toolName()); + return new ToolResult(input.toolCallId(), input.toolName(), + "Error: there is no tool named '" + input.toolName() + "'."); + } + + Object beanInstance = Arc.container().instance(entry.beanClass()).get(); + ToolExecutionRequest request = ToolExecutionRequest.builder() + .id(input.toolCallId()) + .name(input.toolName()) + .arguments(input.arguments()) + .build(); + + String result; + try { + // DefaultToolExecutor parses the JSON arguments against the method signature and invokes + // the tool; on failure it surfaces the error so we can return it to the model. + result = new DefaultToolExecutor(beanInstance, entry.method()).execute(request, null); + } catch (Exception e) { + LOG.warnf("Tool '%s' failed: %s", input.toolName(), e.getMessage()); + result = "Error: " + e.getMessage(); + } + return new ToolResult(input.toolCallId(), input.toolName(), result); + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/AgentToolSpecRegistry.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/AgentToolSpecRegistry.java new file mode 100644 index 0000000000..46a7c54d37 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/AgentToolSpecRegistry.java @@ -0,0 +1,51 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import dev.langchain4j.agent.tool.ToolSpecification; +import io.dapr.quarkus.langchain4j.agent.recovery.AgentToolClassRegistry; +import io.dapr.quarkus.langchain4j.agent.recovery.ToolRegistry; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.util.List; + +/** + * Resolves the {@link ToolSpecification}s advertised to the model for a given agent. + * + *

Thin seam over the existing build-time wiring: {@link AgentToolClassRegistry} maps an + * agent name to its {@code @ToolBox} class names, and {@link ToolRegistry} turns those into + * tool specifications. Because both are available on every replica, the {@code agent-llm} + * activity can resolve tools wherever Dapr places it — no in-memory run state required. + */ +@ApplicationScoped +public class AgentToolSpecRegistry { + + @Inject + ToolRegistry toolRegistry; + + /** + * Returns the tool specifications for the given agent, or an empty list if it has none. + * + * @param agentName the agent name + * @return the tool specifications (never {@code null}) + */ + public List specsFor(String agentName) { + List toolClassNames = AgentToolClassRegistry.get(agentName); + if (toolClassNames == null || toolClassNames.isEmpty()) { + return List.of(); + } + return toolRegistry.getToolSpecsForClasses(toolClassNames); + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/ConditionalBranch.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/ConditionalBranch.java new file mode 100644 index 0000000000..9ebe29f58d --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/ConditionalBranch.java @@ -0,0 +1,36 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import java.util.List; + +/** + * One branch of a durable conditional composite: a sub-agent plus the {@code @ActivationCondition} + * static method that guards it. + * + *

The condition is a pure static predicate (over {@code @V}-named scope values), so it can be + * resolved at build time and invoked reflectively at run time on any replica — no opaque lambda + * to serialize. + * + * @param agent the sub-agent node to run if this branch is selected (leaf or composite) + * @param conditionClass FQCN declaring the {@code @ActivationCondition} method ({@code null} = always) + * @param conditionMethod the static predicate method name ({@code null} = always) + * @param conditionVars the condition method's {@code @V} parameter names, in order + */ +public record ConditionalBranch( + AgentMethodMeta agent, + String conditionClass, + String conditionMethod, + List conditionVars) { +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableAgentInvocationHandler.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableAgentInvocationHandler.java new file mode 100644 index 0000000000..554cd4c51d --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableAgentInvocationHandler.java @@ -0,0 +1,186 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import dev.langchain4j.service.MemoryId; +import dev.langchain4j.service.output.ServiceOutputParser; +import io.dapr.workflows.client.DaprWorkflowClient; +import io.dapr.workflows.client.WorkflowInstanceStatus; +import io.quarkus.arc.Arc; +import org.jboss.logging.Logger; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeoutException; + +/** + * The {@code java.lang.reflect.Proxy} handler behind a durable agent bean. + * + *

Replaces the AiServices-built agent: each call seeds state from the method arguments, starts + * the matching durable workflow ({@code react-agent} for a leaf, {@code durable-*} for a composite), + * waits for it, and returns the result. This is what makes the control-inversion engine "drop-in" — + * the user's {@code @Agent} interface is unchanged. + * + *

A leaf workflow completes with its text ({@code String}); a composite completes with its full + * state {@code Map}, from which the declared {@code outputKey} is read and coerced to the method's + * return type (a structured record is deserialized from its JSON form). + */ +public class DurableAgentInvocationHandler implements InvocationHandler { + + private static final Logger LOG = Logger.getLogger(DurableAgentInvocationHandler.class); + private static final int WAIT_MINUTES = 10; + private static final ServiceOutputParser OUTPUT_PARSER = new ServiceOutputParser(); + + private final Map metasByMethod; + + public DurableAgentInvocationHandler(Map metasByMethod) { + this.metasByMethod = metasByMethod; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Exception { + if (method.getDeclaringClass() == Object.class) { + return switch (method.getName()) { + case "toString" -> "DurableAgentProxy" + metasByMethod.keySet(); + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == (args == null ? null : args[0]); + default -> throw new UnsupportedOperationException(method.getName()); + }; + } + + AgentMethodMeta meta = metasByMethod.get(method.getName()); + if (meta == null) { + throw new IllegalStateException("No durable metadata for agent method " + method.getName()); + } + + // Seed state from the @V parameter names. + Map state = new HashMap<>(); + if (args != null) { + for (int i = 0; i < meta.varNames().size() && i < args.length; i++) { + if (args[i] != null) { + state.put(meta.varNames().get(i), String.valueOf(args[i])); + } + } + } + + Object input = withStructuredOutput(withMemory(DurableInputs.build(meta, state), method, args), method); + String instanceId = meta.agentName() + "-" + UUID.randomUUID(); + LOG.infof("[DurableAgent:%s] starting %s workflow %s", meta.agentName(), meta.workflowName(), instanceId); + + DaprWorkflowClient client = Arc.container().instance(DaprWorkflowClient.class).get(); + client.scheduleNewWorkflow(meta.workflowName(), input, instanceId); + WorkflowInstanceStatus status; + try { + status = client.waitForInstanceCompletion(instanceId, Duration.ofMinutes(WAIT_MINUTES), true); + } catch (TimeoutException e) { + throw new IllegalStateException( + "Durable agent '" + meta.agentName() + "' did not complete within " + WAIT_MINUTES + "m", e); + } + + return coerce(extractResult(meta, status), method); + } + + /** + * Pulls the raw result string: a leaf workflow's text, or a composite's {@code outputKey} value + * from its final state map. + */ + private static String extractResult(AgentMethodMeta meta, WorkflowInstanceStatus status) { + if ("react-agent".equals(meta.workflowName())) { + return status.readOutputAs(String.class); + } + Map finalState = status.readOutputAs(Map.class); + if (finalState == null || meta.outputKey() == null) { + return null; + } + Object value = finalState.get(meta.outputKey()); + return value == null ? null : String.valueOf(value); + } + + /** + * For a leaf agent with a structured (non-String) return type, append LangChain4j's + * output-format instructions to the rendered user message so the model emits parseable JSON. + * Composites produce their result via an {@code @Output} combiner — not directly from the model — + * so they are left untouched. + */ + private static Object withStructuredOutput(Object input, Method method) { + Class returnType = method.getReturnType(); + if (returnType == String.class || returnType == void.class || returnType == Void.class) { + return input; + } + if (!(input instanceof ReActInput leaf)) { + return input; + } + String instructions = OUTPUT_PARSER.outputFormatInstructions(method.getGenericReturnType()); + if (instructions == null || instructions.isBlank()) { + return input; + } + String userMessage = (leaf.userMessage() == null ? "" : leaf.userMessage()) + "\n" + instructions; + return new ReActInput(leaf.agentName(), leaf.systemMessage(), userMessage, + leaf.priorMessagesJson(), leaf.memoryId(), leaf.maxSteps()); + } + + /** + * For a leaf agent with a {@code @MemoryId} parameter, loads the prior conversation at the entry + * (so it is captured into the workflow input and stays replay-stable) and tags the input with the + * memory id so the workflow persists this turn at the end. Agents without {@code @MemoryId} are + * stateless per call, exactly as AiServices is when no chat memory is configured. + */ + private static Object withMemory(Object input, Method method, Object[] args) { + if (!(input instanceof ReActInput leaf)) { + return input; + } + int idx = memoryIdIndex(method); + if (idx < 0 || args == null || idx >= args.length || args[idx] == null) { + return input; + } + String memoryId = String.valueOf(args[idx]); + String priorMessagesJson = DurableChatMemory.loadJson(memoryId); + return new ReActInput(leaf.agentName(), leaf.systemMessage(), leaf.userMessage(), + priorMessagesJson, memoryId, leaf.maxSteps()); + } + + private static int memoryIdIndex(Method method) { + Parameter[] params = method.getParameters(); + for (int i = 0; i < params.length; i++) { + if (params[i].isAnnotationPresent(MemoryId.class)) { + return i; + } + } + return -1; + } + + /** + * Coerces the workflow's raw result to the method's return type, reusing LangChain4j's + * {@link ServiceOutputParser} (the same parser AiServices uses) so JSON objects, enums, + * primitives and markdown-fenced output are all handled. + */ + private static Object coerce(String raw, Method method) { + Class returnType = method.getReturnType(); + if (returnType == void.class || returnType == Void.class) { + return null; + } + if (returnType == String.class) { + return raw; + } + if (raw == null) { + return null; + } + return OUTPUT_PARSER.parseText(method.getGenericReturnType(), raw); + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableAgentProxyRecorder.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableAgentProxyRecorder.java new file mode 100644 index 0000000000..afd03c6e5e --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableAgentProxyRecorder.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import io.quarkus.arc.SyntheticCreationalContext; +import io.quarkus.runtime.annotations.Recorder; + +import java.lang.reflect.Proxy; +import java.util.Map; +import java.util.function.Function; + +/** + * Builds the synthetic-bean instance for a durable agent: a {@code java.lang.reflect.Proxy} + * implementing the agent interface, backed by {@link DurableAgentInvocationHandler}. + * + *

Mirrors {@code AgenticRecorder.createAiAgent} but replaces the AiServices-built agent with + * one that runs as a Dapr Workflow. The deployment registers this as an alternative synthetic + * bean (higher priority) so it wins over the quarkiverse-built bean. + */ +@Recorder +public class DurableAgentProxyRecorder { + + /** + * Returns the creation function for the synthetic agent bean. + * + * @param ifaceName the agent interface FQCN + * @param methodMetas per-method durable metadata, keyed by method name + * @return a function that creates the proxy instance + */ + public Function, Object> createAgentProxy( + String ifaceName, Map methodMetas) { + return ctx -> { + try { + Class iface = Class.forName( + ifaceName, true, Thread.currentThread().getContextClassLoader()); + return Proxy.newProxyInstance(iface.getClassLoader(), new Class[]{iface}, + new DurableAgentInvocationHandler(methodMetas)); + } catch (ClassNotFoundException e) { + throw new IllegalStateException("Cannot load durable agent interface " + ifaceName, e); + } + }; + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableChatMemory.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableChatMemory.java new file mode 100644 index 0000000000..e18e55a021 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableChatMemory.java @@ -0,0 +1,79 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.ChatMessageSerializer; +import io.dapr.client.DaprClient; +import io.dapr.quarkus.langchain4j.memory.KeyValueChatMemoryStore; +import io.quarkus.arc.Arc; +import org.eclipse.microprofile.config.ConfigProvider; + +import java.util.ArrayList; +import java.util.List; + +/** + * Chat-memory load/save for durable agents, backed by {@link KeyValueChatMemoryStore} (Dapr state). + * + *

Load runs at the proxy entry, so the prior messages are captured into the workflow + * input and stay stable across replay. Save runs in the {@code memory-save} activity at the + * end of the run and is idempotent: it replaces the stored conversation with the windowed + * full history, so a redelivered (at-least-once) activity cannot duplicate messages. + * + *

The conversation is windowed to the last {@link #MAX_MESSAGES} messages. The state store name + * comes from {@code dapr.agentic.chat-memory.store-name} (default {@code kvstore}). + */ +final class DurableChatMemory { + + static final int MAX_MESSAGES = 20; + private static final String STORE_NAME_CONFIG = "dapr.agentic.chat-memory.store-name"; + + private DurableChatMemory() { + } + + private static KeyValueChatMemoryStore store() { + DaprClient client = Arc.container().instance(DaprClient.class).get(); + String storeName = ConfigProvider.getConfig() + .getOptionalValue(STORE_NAME_CONFIG, String.class).orElse("kvstore"); + return new KeyValueChatMemoryStore(client, storeName); + } + + /** + * Loads the windowed prior conversation as LangChain4j JSON, or {@code null} if empty. + * + * @param memoryId the conversation id + * @return the serialized prior messages, or {@code null} + */ + static String loadJson(String memoryId) { + List windowed = window(store().getMessages(memoryId)); + return windowed.isEmpty() ? null : ChatMessageSerializer.messagesToJson(windowed); + } + + /** + * Replaces the stored conversation with the windowed message list (idempotent). + * + * @param memoryId the conversation id + * @param conversation the full conversation to persist + */ + static void save(String memoryId, List conversation) { + store().updateMessages(memoryId, window(conversation)); + } + + private static List window(List messages) { + if (messages.size() <= MAX_MESSAGES) { + return messages; + } + return new ArrayList<>(messages.subList(messages.size() - MAX_MESSAGES, messages.size())); + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableChildren.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableChildren.java new file mode 100644 index 0000000000..da14865852 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableChildren.java @@ -0,0 +1,74 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import io.dapr.durabletask.Task; +import io.dapr.workflows.WorkflowContext; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Runs sub-agent child workflows and merges their results into the parent state — the durable + * equivalent of LangChain4j's shared agentic scope. + * + *

A leaf child ({@code react-agent}) returns a {@code String} stored under its {@code outputKey}; + * a composite child ({@code durable-*}) returns its full state {@code Map}, which is merged into the + * parent so inner keys (e.g. a nested parallel's outputs) propagate up to later sub-agents. + */ +final class DurableChildren { + + private DurableChildren() { + } + + /** Runs children one at a time, merging each result before the next is built (rendered). */ + static void runSequential(WorkflowContext ctx, List children, Map state) { + for (AgentMethodMeta child : children) { + Object out = ctx.callChildWorkflow( + child.workflowName(), DurableInputs.build(child, state), Object.class).await(); + merge(state, child, out); + } + } + + /** Runs children concurrently (all built from the same state), then merges all results. */ + static void runParallel(WorkflowContext ctx, List children, Map state) { + if (children.isEmpty()) { + return; + } + List> tasks = new ArrayList<>(); + for (AgentMethodMeta child : children) { + tasks.add(ctx.callChildWorkflow( + child.workflowName(), DurableInputs.build(child, state), Object.class)); + } + List results = ctx.allOf(tasks).await(); + for (int i = 0; i < children.size(); i++) { + merge(state, children.get(i), results.get(i)); + } + } + + private static void merge(Map state, AgentMethodMeta child, Object out) { + if (out instanceof Map childState) { + // composite child: propagate its whole scope (inner keys included) + for (Map.Entry entry : childState.entrySet()) { + if (entry.getValue() != null) { + state.put(String.valueOf(entry.getKey()), String.valueOf(entry.getValue())); + } + } + } else if (out != null) { + // leaf child: its text result, stored under its output key + state.put(child.outputKey(), String.valueOf(out)); + } + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableConditionalInput.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableConditionalInput.java new file mode 100644 index 0000000000..0358f47fd5 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableConditionalInput.java @@ -0,0 +1,35 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import java.util.List; +import java.util.Map; + +/** + * Input to {@link DurableConditionalWorkflow}. + * + *

Branches are evaluated in order; the first whose {@code @ActivationCondition} predicate + * returns {@code true} (given the seed state) runs its sub-agent. + * + * @param branches the candidate branches, in declaration order + * @param initialState seed state for condition evaluation and template rendering + * @param finalOutputKey state key to return; {@code null} returns the chosen agent's output + * @param combiner optional {@code @Output} combiner producing the result; {@code null} if none + */ +public record DurableConditionalInput( + List branches, + Map initialState, + String finalOutputKey, + OutputCombiner combiner) { +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableConditionalWorkflow.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableConditionalWorkflow.java new file mode 100644 index 0000000000..5657d44a8f --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableConditionalWorkflow.java @@ -0,0 +1,137 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import io.dapr.workflows.Workflow; +import io.dapr.workflows.WorkflowStub; +import io.quarkiverse.dapr.workflows.WorkflowMetadata; +import jakarta.enterprise.context.ApplicationScoped; +import org.jboss.logging.Logger; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Durable conditional composite: runs every sub-agent whose {@code @ActivationCondition} + * predicate is true, concurrently, as {@code react-agent} child workflows. + * + *

Control-inversion replacement for {@code ConditionalOrchestrationWorkflow}, matching + * LangChain4j's {@code ConditionalPlanner} semantics (filter sub-agents by predicate, then call + * the whole matching set) — not first-match. The activation conditions are pure static + * predicates, invoked reflectively here — deterministic given the (replayed) state, hence + * replay-safe. (A condition that performs I/O would break determinism; conditions must be pure.) + */ +@ApplicationScoped +@WorkflowMetadata(name = "durable-conditional") +public class DurableConditionalWorkflow implements Workflow { + + private static final Logger LOG = Logger.getLogger(DurableConditionalWorkflow.class); + + @Override + public WorkflowStub create() { + return ctx -> { + DurableConditionalInput input = ctx.getInput(DurableConditionalInput.class); + Map state = new HashMap<>(input.initialState()); + + // Select every branch whose activation condition holds (LangChain4j semantics), then run + // the matching sub-agents concurrently — merging leaf outputs and nested composite states. + List selected = new ArrayList<>(); + for (ConditionalBranch branch : input.branches()) { + if (matches(branch, state)) { + selected.add(branch.agent()); + } + } + DurableChildren.runParallel(ctx, selected, state); + + String result = DurableOutput.resolve(input.combiner(), input.finalOutputKey(), state, null); + if (input.finalOutputKey() != null && result != null) { + state.put(input.finalOutputKey(), result); + } + ctx.complete(state); + }; + } + + private static boolean matches(ConditionalBranch branch, Map state) { + if (branch.conditionClass() == null || branch.conditionMethod() == null) { + return true; // unconditional branch + } + try { + Class declaring = Class.forName( + branch.conditionClass(), true, Thread.currentThread().getContextClassLoader()); + Method method = findMethod(declaring, branch.conditionMethod()); + Object[] args = new Object[method.getParameterCount()]; + Class[] types = method.getParameterTypes(); + for (int i = 0; i < args.length; i++) { + String value = i < branch.conditionVars().size() ? state.get(branch.conditionVars().get(i)) : null; + args[i] = coerce(value, types[i]); + } + return Boolean.TRUE.equals(method.invoke(null, args)); + } catch (ReflectiveOperationException e) { + LOG.warnf("Activation condition %s#%s failed: %s — treating as not matched", + branch.conditionClass(), branch.conditionMethod(), e.getMessage()); + return false; + } + } + + private static Method findMethod(Class declaring, String name) throws NoSuchMethodException { + for (Method m : declaring.getDeclaredMethods()) { + if (m.getName().equals(name)) { + m.setAccessible(true); + return m; + } + } + throw new NoSuchMethodException(declaring.getName() + "#" + name); + } + + private static Object coerce(String value, Class type) { + if (value == null) { + return type.isPrimitive() ? defaultPrimitive(type) : null; + } + if (type == String.class) { + return value; + } + if (type == int.class || type == Integer.class) { + return Integer.parseInt(value); + } + if (type == long.class || type == Long.class) { + return Long.parseLong(value); + } + if (type == boolean.class || type == Boolean.class) { + return Boolean.parseBoolean(value); + } + if (type == double.class || type == Double.class) { + return Double.parseDouble(value); + } + return value; + } + + private static Object defaultPrimitive(Class type) { + if (type == boolean.class) { + return false; + } + if (type == int.class) { + return 0; + } + if (type == long.class) { + return 0L; + } + if (type == double.class) { + return 0d; + } + return 0; + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableInputs.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableInputs.java new file mode 100644 index 0000000000..ea5c575293 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableInputs.java @@ -0,0 +1,56 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import java.util.Map; + +/** + * Builds the workflow input for an {@link AgentMethodMeta} node, given the current state. + * + *

Shared by the proxy entry point and the composite workflows so a node can be dispatched the + * same way wherever it appears (top-level or as a nested sub-agent). + */ +final class DurableInputs { + + static final int MAX_STEPS = 16; + + private DurableInputs() { + } + + /** + * Builds the input object for {@code node.workflowName()}. + * + * @param node the node to run + * @param state the current state (rendered into leaf templates; seeds a composite child) + * @return a {@link ReActInput} (leaf) or a {@code Durable*Input} (composite) + */ + static Object build(AgentMethodMeta node, Map state) { + return switch (node.workflowName()) { + case "react-agent" -> new ReActInput( + node.agentName(), + DurableRendering.render(node.systemTemplate(), state), + DurableRendering.render(node.userTemplate(), state), + null, + null, + MAX_STEPS); + case "durable-sequence", "durable-parallel" -> new DurableSequenceInput( + node.subAgents(), state, node.outputKey(), node.combiner()); + case "durable-loop" -> new DurableLoopInput( + node.subAgents(), state, node.outputKey(), node.maxIterations(), node.combiner()); + case "durable-conditional" -> new DurableConditionalInput( + node.branches(), state, node.outputKey(), node.combiner()); + default -> throw new IllegalStateException("Unsupported durable workflow: " + node.workflowName()); + }; + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableLoopInput.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableLoopInput.java new file mode 100644 index 0000000000..a4af11d78f --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableLoopInput.java @@ -0,0 +1,34 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import java.util.List; +import java.util.Map; + +/** + * Input to {@link DurableLoopWorkflow}. + * + * @param subAgents the steps run, in order, every iteration + * @param initialState seed state for template rendering + * @param finalOutputKey state key to return; {@code null} returns the last step's output + * @param maxIterations how many times to run the sequence + * @param combiner optional {@code @Output} combiner producing the result; {@code null} if none + */ +public record DurableLoopInput( + List subAgents, + Map initialState, + String finalOutputKey, + int maxIterations, + OutputCombiner combiner) { +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableLoopWorkflow.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableLoopWorkflow.java new file mode 100644 index 0000000000..787052b16b --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableLoopWorkflow.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import io.dapr.workflows.Workflow; +import io.dapr.workflows.WorkflowStub; +import io.quarkiverse.dapr.workflows.WorkflowMetadata; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.HashMap; +import java.util.Map; + +/** + * Durable loop composite: runs the sub-agent sequence a fixed number of iterations, threading + * state across iterations (so each pass can refine the previous output). + * + *

Control-inversion replacement for {@code LoopOrchestrationWorkflow}. This representative + * uses a counted loop; a predicate-based exit would evaluate a condition activity at the top + * of each iteration (mirroring the existing {@code ExitConditionCheckActivity}). + */ +@ApplicationScoped +@WorkflowMetadata(name = "durable-loop") +public class DurableLoopWorkflow implements Workflow { + + @Override + public WorkflowStub create() { + return ctx -> { + DurableLoopInput input = ctx.getInput(DurableLoopInput.class); + Map state = new HashMap<>(input.initialState()); + int iterations = input.maxIterations() > 0 ? input.maxIterations() : 1; + + for (int iteration = 0; iteration < iterations; iteration++) { + DurableChildren.runSequential(ctx, input.subAgents(), state); + } + + String result = DurableOutput.resolve(input.combiner(), input.finalOutputKey(), state, null); + if (input.finalOutputKey() != null && result != null) { + state.put(input.finalOutputKey(), result); + } + ctx.complete(state); + }; + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableOutput.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableOutput.java new file mode 100644 index 0000000000..611493b2b1 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableOutput.java @@ -0,0 +1,118 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.lang.reflect.Method; +import java.util.Map; + +/** + * Resolves a composite's final result: invokes the static {@code @Output} combiner (if present) + * with sub-agent outputs read from the accumulated state, otherwise returns the {@code outputKey} + * value (or {@code null}). + * + *

{@code @Output} methods are pure combiners (format/aggregate scope values), so invoking them + * reflectively in the orchestrator is deterministic and replay-safe. + */ +final class DurableOutput { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private DurableOutput() { + } + + /** + * Computes the composite result as a String (a structured {@code @Output} record is serialized to + * JSON so it can live in the state map; the proxy deserializes it back at the boundary). + * + * @param combiner the {@code @Output} reference, or {@code null} + * @param outputKey the composite output key (used when there is no combiner) + * @param state the accumulated sub-agent outputs (by scope key) + * @param fallback value to return when neither combiner nor outputKey yields a result + * @return the composite result as a String, or {@code null} + */ + static String resolve(OutputCombiner combiner, String outputKey, + Map state, String fallback) { + if (combiner != null) { + return asString(invoke(combiner, state)); + } + if (outputKey != null && state.get(outputKey) != null) { + return state.get(outputKey); + } + return fallback; + } + + private static String asString(Object value) { + if (value == null) { + return null; + } + if (value instanceof String s) { + return s; + } + try { + return MAPPER.writeValueAsString(value); + } catch (JsonProcessingException e) { + return String.valueOf(value); + } + } + + private static Object invoke(OutputCombiner combiner, Map state) { + try { + Class declaring = Class.forName( + combiner.declaringClass(), true, Thread.currentThread().getContextClassLoader()); + Method method = findMethod(declaring, combiner.methodName()); + Class[] types = method.getParameterTypes(); + Object[] args = new Object[types.length]; + for (int i = 0; i < types.length; i++) { + String value = i < combiner.paramNames().size() ? state.get(combiner.paramNames().get(i)) : null; + args[i] = coerce(value, types[i]); + } + return method.invoke(null, args); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Failed to invoke @Output combiner " + + combiner.declaringClass() + "#" + combiner.methodName(), e); + } + } + + private static Method findMethod(Class declaring, String name) throws NoSuchMethodException { + for (Method m : declaring.getDeclaredMethods()) { + if (m.getName().equals(name)) { + m.setAccessible(true); + return m; + } + } + throw new NoSuchMethodException(declaring.getName() + "#" + name); + } + + private static Object coerce(String value, Class type) { + if (type == String.class || value == null) { + return value; + } + if (type == int.class || type == Integer.class) { + return Integer.parseInt(value); + } + if (type == long.class || type == Long.class) { + return Long.parseLong(value); + } + if (type == boolean.class || type == Boolean.class) { + return Boolean.parseBoolean(value); + } + if (type == double.class || type == Double.class) { + return Double.parseDouble(value); + } + return value; + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableParallelWorkflow.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableParallelWorkflow.java new file mode 100644 index 0000000000..709ebc66e7 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableParallelWorkflow.java @@ -0,0 +1,51 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import io.dapr.workflows.Workflow; +import io.dapr.workflows.WorkflowStub; +import io.quarkiverse.dapr.workflows.WorkflowMetadata; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.HashMap; +import java.util.Map; + +/** + * Durable parallel composite: runs all sub-agents concurrently as child workflows from the same + * seed state, then merges each result (leaf output under its key; a nested composite's whole state) + * and applies the optional {@code @Output} combiner. + * + *

Control-inversion replacement for {@code ParallelOrchestrationWorkflow}; reuses + * {@link DurableSequenceInput} (sub-agents are independent here — no inter-step threading). + */ +@ApplicationScoped +@WorkflowMetadata(name = "durable-parallel") +public class DurableParallelWorkflow implements Workflow { + + @Override + public WorkflowStub create() { + return ctx -> { + DurableSequenceInput input = ctx.getInput(DurableSequenceInput.class); + Map state = new HashMap<>(input.initialState()); + + DurableChildren.runParallel(ctx, input.subAgents(), state); + + String result = DurableOutput.resolve(input.combiner(), input.finalOutputKey(), state, null); + if (input.finalOutputKey() != null && result != null) { + state.put(input.finalOutputKey(), result); + } + ctx.complete(state); + }; + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableRendering.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableRendering.java new file mode 100644 index 0000000000..b6c1df8ae2 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableRendering.java @@ -0,0 +1,53 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import dev.langchain4j.model.input.PromptTemplate; + +import java.util.HashMap; +import java.util.Map; + +/** + * Shared prompt-template rendering for the durable agents, using LangChain4j's + * {@link PromptTemplate} engine — the same one AiServices uses — so rendered prompts match + * non-durable behavior ({@code {{var}}}, the single-variable {@code {{it}}}, property navigation + * like {@code {{user.name}}}, and strict missing-variable checking). + * + *

Determinism: rendering must be a pure function of the (captured) call state, because it + * runs both at the proxy entry and inside composite orchestrators, which replay. LangChain4j's + * wall-clock template variables ({@code current_date} / {@code current_time} / + * {@code current_date_time}) are therefore unsupported here — their value would differ across a + * replay and break determinism. Pass any time-dependent value as a method argument instead (it is + * captured once, at the entry). + */ +final class DurableRendering { + + private DurableRendering() { + } + + /** + * Renders {@code template} against {@code state}. + * + * @param template the template (may be {@code null}) + * @param state the variables to render from + * @return the rendered string, or {@code null} if the template was {@code null} + */ + static String render(String template, Map state) { + if (template == null) { + return null; + } + Map variables = new HashMap<>(state); + return PromptTemplate.from(template).apply(variables).text(); + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableSequenceInput.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableSequenceInput.java new file mode 100644 index 0000000000..93da4e33c0 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableSequenceInput.java @@ -0,0 +1,33 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import java.util.List; +import java.util.Map; + +/** + * Input to {@link DurableSequenceWorkflow}. + * + * @param subAgents the steps to run in order, each as a {@code react-agent} child + * @param initialState seed state (e.g. the request arguments) for template rendering + * @param finalOutputKey state key to return as the composite result; {@code null} returns the + * last step's output + * @param combiner optional {@code @Output} combiner producing the result; {@code null} if none + */ +public record DurableSequenceInput( + List subAgents, + Map initialState, + String finalOutputKey, + OutputCombiner combiner) { +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableSequenceWorkflow.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableSequenceWorkflow.java new file mode 100644 index 0000000000..4e6a52e625 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/DurableSequenceWorkflow.java @@ -0,0 +1,60 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import io.dapr.workflows.Workflow; +import io.dapr.workflows.WorkflowStub; +import io.quarkiverse.dapr.workflows.WorkflowMetadata; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.HashMap; +import java.util.Map; + +/** + * Durable sequential composite: runs each sub-agent in order as a {@code react-agent} child + * workflow, threading shared state between steps. + * + *

Children are called directly via {@link io.dapr.workflows.WorkflowContext#callChildWorkflow}. + * Because each child is itself a durable workflow (a leaf {@link ReActAgentWorkflow} or a nested + * composite), the whole tree is replayable and replica-agnostic — all agent state lives in + * workflow history, with no in-memory planner, queue, or per-thread context. + * + *

The parallel / loop / conditional composites follow the same shape ({@code allOf} for + * parallel, a counted/condition loop, an {@code if} on a condition). + */ +@ApplicationScoped +@WorkflowMetadata(name = "durable-sequence") +public class DurableSequenceWorkflow implements Workflow { + + private static final int CHILD_MAX_STEPS = 16; + + @Override + public WorkflowStub create() { + return ctx -> { + DurableSequenceInput input = ctx.getInput(DurableSequenceInput.class); + Map state = new HashMap<>(input.initialState()); + + // Each sub-agent (leaf or nested composite) runs as a child workflow; its outputs merge into + // state before the next renders. + DurableChildren.runSequential(ctx, input.subAgents(), state); + + String result = DurableOutput.resolve(input.combiner(), input.finalOutputKey(), state, null); + if (input.finalOutputKey() != null && result != null) { + state.put(input.finalOutputKey(), result); + } + // Complete with the full state so a parent composite can propagate inner keys upward. + ctx.complete(state); + }; + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/LlmInput.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/LlmInput.java new file mode 100644 index 0000000000..ec23319d96 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/LlmInput.java @@ -0,0 +1,24 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +/** + * Input to the {@code agent-llm} activity: everything needed to make one model call, + * with no dependency on in-memory state — so the activity can run on any replica. + * + * @param agentName the agent name (used to resolve tool specifications) + * @param messagesJson the full conversation so far, serialized as LangChain4j JSON + */ +public record LlmInput(String agentName, String messagesJson) { +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/LlmResult.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/LlmResult.java new file mode 100644 index 0000000000..d1fd132bda --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/LlmResult.java @@ -0,0 +1,26 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +/** + * Output of the {@code agent-llm} activity. + * + *

Carries the full {@code AiMessage} serialized as LangChain4j JSON — including + * any tool-execution requests (ids, names, arguments). This losslessness is what lets the + * orchestrator rebuild the exact conversation on replay; a text-only summary would not. + * + * @param aiMessageJson the assistant message serialized as LangChain4j JSON + */ +public record LlmResult(String aiMessageJson) { +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/MemorySaveInput.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/MemorySaveInput.java new file mode 100644 index 0000000000..62cdabd614 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/MemorySaveInput.java @@ -0,0 +1,24 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +/** + * Input to the {@code memory-save} activity: the conversation to persist for a {@code @MemoryId} + * agent, serialized as LangChain4j message JSON. + * + * @param memoryId the conversation/memory id + * @param messagesJson the full (windowed) conversation as LangChain4j JSON + */ +public record MemorySaveInput(String memoryId, String messagesJson) { +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/OutputCombiner.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/OutputCombiner.java new file mode 100644 index 0000000000..8ba93d4e7b --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/OutputCombiner.java @@ -0,0 +1,27 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import java.util.List; + +/** + * Reference to a composite's static {@code @Output} method, which combines sub-agent outputs + * (matched from scope by parameter name) into the composite's result. + * + * @param declaringClass FQCN declaring the {@code @Output} method + * @param methodName the static method name + * @param paramNames the method's parameter names, in order (each a scope key to read) + */ +public record OutputCombiner(String declaringClass, String methodName, List paramNames) { +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/ReActAgentWorkflow.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/ReActAgentWorkflow.java new file mode 100644 index 0000000000..951108bff8 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/ReActAgentWorkflow.java @@ -0,0 +1,138 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +import dev.langchain4j.agent.tool.ToolExecutionRequest; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.ChatMessageDeserializer; +import dev.langchain4j.data.message.ChatMessageSerializer; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.ToolExecutionResultMessage; +import dev.langchain4j.data.message.UserMessage; +import io.dapr.durabletask.Task; +import io.dapr.workflows.Workflow; +import io.dapr.workflows.WorkflowContext; +import io.dapr.workflows.WorkflowStub; +import io.quarkiverse.dapr.workflows.WorkflowMetadata; +import jakarta.enterprise.context.ApplicationScoped; +import org.jboss.logging.Logger; + +import java.util.ArrayList; +import java.util.List; + +/** + * Durable ReAct loop for a single {@code @Agent}, run as a Dapr Workflow (control-inversion + * approach — the workflow owns the loop instead of recording an in-memory one). + * + *

This is the reimplementation of LangChain4j's {@code ToolService.executeInferenceAndToolsLoop} + * against a {@link io.dapr.workflows.WorkflowContext}: the only non-deterministic steps — the + * model call and each tool call — are activities ({@code agent-llm} / {@code agent-tool}), and + * the loop control is deterministic given the recorded activity outputs. + * + *

Why this gives durability, recovery and scale for free

+ *
    + *
  • Per-call recovery: on replay, completed {@code agent-llm}/{@code agent-tool} + * calls return from history; the loop resumes at the next call.
  • + *
  • Horizontal scale: activities are pure functions of their input, so Dapr's + * random activity placement across replicas is correct — there is no in-memory per-run + * context, blocked future, or {@code ThreadLocal}.
  • + *
  • Observability: every step is a history entry; {@link AgentStatus} is published + * as custom status.
  • + *
+ * + *

Normally started through the drop-in {@code @Agent} proxy entry point (which renders the + * prompt from the method args); it can also be scheduled directly via + * {@code DaprWorkflowClient.scheduleNewWorkflow("react-agent", input, id)}. Long agents should + * periodically {@code ctx.continueAsNew(...)} to cap history. + */ +@ApplicationScoped +@WorkflowMetadata(name = "react-agent") +public class ReActAgentWorkflow implements Workflow { + + private static final Logger LOG = Logger.getLogger(ReActAgentWorkflow.class); + private static final int DEFAULT_MAX_STEPS = 16; + + @Override + public WorkflowStub create() { + return ctx -> { + ReActInput input = ctx.getInput(ReActInput.class); + + // Deterministic seed: prior memory (if any) + rendered system/user messages. + List messages = new ArrayList<>(); + if (input.priorMessagesJson() != null && !input.priorMessagesJson().isBlank()) { + messages.addAll(ChatMessageDeserializer.messagesFromJson(input.priorMessagesJson())); + } + if (input.systemMessage() != null && !input.systemMessage().isBlank()) { + messages.add(SystemMessage.from(input.systemMessage())); + } + messages.add(UserMessage.from(input.userMessage())); + + int stepsLeft = input.maxSteps() > 0 ? input.maxSteps() : DEFAULT_MAX_STEPS; + while (stepsLeft-- > 0) { + + // (1) Model call as an activity — replayed from history on recovery, runs on any replica. + LlmResult llm = ctx.callActivity("agent-llm", + new LlmInput(input.agentName(), ChatMessageSerializer.messagesToJson(messages)), + LlmResult.class).await(); + AiMessage aiMessage = (AiMessage) ChatMessageDeserializer.messageFromJson(llm.aiMessageJson()); + messages.add(aiMessage); + ctx.setCustomStatus(new AgentStatus( + input.agentName(), messages.size(), !aiMessage.hasToolExecutionRequests())); + + if (!aiMessage.hasToolExecutionRequests()) { + saveMemory(ctx, input, messages); + ctx.complete(aiMessage.text()); + return; + } + + // (2) Tool calls as activities, fanned out in parallel — each replica-agnostic. + List> toolTasks = new ArrayList<>(); + for (ToolExecutionRequest request : aiMessage.toolExecutionRequests()) { + toolTasks.add(ctx.callActivity("agent-tool", + new ToolInput(input.agentName(), request.id(), request.name(), request.arguments()), + ToolResult.class)); + } + for (ToolResult result : ctx.allOf(toolTasks).await()) { + messages.add(ToolExecutionResultMessage.from( + result.toolCallId(), result.toolName(), result.resultText())); + } + } + + LOG.warnf("[ReAct:%s] exceeded maxSteps without a final answer", input.agentName()); + throw new IllegalStateException( + "Agent '" + input.agentName() + "' exceeded maxSteps without a final answer"); + }; + } + + /** + * Persists the conversation for a {@code @MemoryId} agent via the {@code memory-save} activity + * (durable + idempotent). The system message is excluded — it is regenerated from the template + * each turn, not part of the stored history. + */ + private static void saveMemory(WorkflowContext ctx, ReActInput input, List messages) { + if (input.memoryId() == null || input.memoryId().isBlank()) { + return; + } + List toPersist = new ArrayList<>(); + for (ChatMessage message : messages) { + if (!(message instanceof SystemMessage)) { + toPersist.add(message); + } + } + ctx.callActivity("memory-save", + new MemorySaveInput(input.memoryId(), ChatMessageSerializer.messagesToJson(toPersist)), + String.class).await(); + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/ReActInput.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/ReActInput.java new file mode 100644 index 0000000000..ca4575a4fd --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/ReActInput.java @@ -0,0 +1,38 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +/** + * Input to {@link ReActAgentWorkflow}. + * + *

The messages are already rendered (template variables substituted) at the + * entry point where the call arguments are available — so the durable loop never deals + * with raw {@code {{var}}} templates, and replay-based recovery cannot lose the binding. + * + * @param agentName the agent name (used to resolve tool specifications) + * @param systemMessage the rendered system message, or {@code null} + * @param userMessage the rendered user message + * @param priorMessagesJson serialized prior chat history (LangChain4j JSON), or {@code null}/blank + * @param memoryId the {@code @MemoryId} value for persisting this turn, or {@code null} + * when the agent has no chat memory + * @param maxSteps maximum ReAct iterations before giving up ({@code <= 0} uses the default) + */ +public record ReActInput( + String agentName, + String systemMessage, + String userMessage, + String priorMessagesJson, + String memoryId, + int maxSteps) { +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/ToolInput.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/ToolInput.java new file mode 100644 index 0000000000..b9058f3fa0 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/ToolInput.java @@ -0,0 +1,25 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +/** + * Input to the {@code agent-tool} activity: one tool call requested by the model. + * + * @param agentName the agent name (used to resolve the tool) + * @param toolCallId the LLM-assigned tool-call id (echoed back to correlate the result) + * @param toolName the tool name to invoke + * @param arguments the tool arguments as the JSON string produced by the model + */ +public record ToolInput(String agentName, String toolCallId, String toolName, String arguments) { +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/ToolResult.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/ToolResult.java new file mode 100644 index 0000000000..68d46967cc --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/durable/ToolResult.java @@ -0,0 +1,24 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.durable; + +/** + * Output of the {@code agent-tool} activity. + * + * @param toolCallId the tool-call id this result corresponds to + * @param toolName the tool that produced the result + * @param resultText the tool's result, rendered as text for the model + */ +public record ToolResult(String toolCallId, String toolName, String resultText) { +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/memory/KeyValueChatMemoryStore.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/memory/KeyValueChatMemoryStore.java new file mode 100644 index 0000000000..9f7ba5880a --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/memory/KeyValueChatMemoryStore.java @@ -0,0 +1,91 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.memory; + +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.ChatMessageDeserializer; +import dev.langchain4j.data.message.ChatMessageSerializer; +import dev.langchain4j.store.memory.chat.ChatMemoryStore; +import io.dapr.client.DaprClient; +import io.dapr.client.domain.State; + +import java.util.Collections; +import java.util.List; +import java.util.function.Function; + +/** + * A {@link ChatMemoryStore} backed by Dapr's key-value state store. + * + *

Messages are serialized to JSON using {@link ChatMessageSerializer} and stored + * under the key {@code memoryId.toString()} in the configured Dapr state store. + */ +public class KeyValueChatMemoryStore implements ChatMemoryStore { + + private final DaprClient daprClient; + private final String stateStoreName; + private final Function, String> serializer; + private final Function> deserializer; + + /** + * Creates a new KeyValueChatMemoryStore with default serializers. + * + * @param daprClient the Dapr client + * @param stateStoreName the state store name + */ + public KeyValueChatMemoryStore(DaprClient daprClient, String stateStoreName) { + this(daprClient, stateStoreName, + ChatMessageSerializer::messagesToJson, + ChatMessageDeserializer::messagesFromJson); + } + + /** + * Creates a new KeyValueChatMemoryStore with custom serializers. + * + * @param daprClient the Dapr client + * @param stateStoreName the state store name + * @param serializer the message serializer + * @param deserializer the message deserializer + */ + KeyValueChatMemoryStore(DaprClient daprClient, String stateStoreName, + Function, String> serializer, + Function> deserializer) { + this.daprClient = daprClient; + this.stateStoreName = stateStoreName; + this.serializer = serializer; + this.deserializer = deserializer; + } + + @Override + public List getMessages(Object memoryId) { + String key = memoryId.toString(); + State state = daprClient.getState(stateStoreName, key, String.class).block(); + if (state == null || state.getValue() == null || state.getValue().isEmpty()) { + return Collections.emptyList(); + } + return deserializer.apply(state.getValue()); + } + + @Override + public void updateMessages(Object memoryId, List messages) { + String key = memoryId.toString(); + String json = serializer.apply(messages); + daprClient.saveState(stateStoreName, key, json).block(); + } + + @Override + public void deleteMessages(Object memoryId) { + String key = memoryId.toString(); + daprClient.deleteState(stateStoreName, key).block(); + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/scope/AgenticScopeStoreInitializer.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/scope/AgenticScopeStoreInitializer.java new file mode 100644 index 0000000000..70c7dccaba --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/scope/AgenticScopeStoreInitializer.java @@ -0,0 +1,60 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.scope; + +import dev.langchain4j.agentic.scope.AgenticScopePersister; +import io.dapr.client.DaprClient; +import io.quarkus.runtime.StartupEvent; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; + +/** + * Registers {@link DaprAgenticScopeStore} as LangChain4j's agentic scope persistence + * provider at startup, so agentic workflow state (shared state, conversation context) + * is checkpointed to a Dapr state store on every update. + * + *

Opt-in via configuration: + *

{@code
+ * dapr.agentic.scope-store.enabled=true
+ * dapr.agentic.scope-store.name=kvstore
+ * }
+ */ +@ApplicationScoped +public class AgenticScopeStoreInitializer { + + private static final Logger LOG = Logger.getLogger(AgenticScopeStoreInitializer.class); + + @Inject + DaprClient daprClient; + + @Inject + @ConfigProperty(name = "dapr.agentic.scope-store.enabled", defaultValue = "false") + boolean enabled; + + @Inject + @ConfigProperty(name = "dapr.agentic.scope-store.name", defaultValue = "kvstore") + String stateStoreName; + + void onStart(@Observes StartupEvent event) { + if (!enabled) { + return; + } + AgenticScopePersister.setStore(new DaprAgenticScopeStore(daprClient, stateStoreName)); + LOG.infof("Registered DaprAgenticScopeStore (state store: %s) — " + + "agentic scopes are checkpointed to Dapr", stateStoreName); + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/scope/DaprAgenticScopeStore.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/scope/DaprAgenticScopeStore.java new file mode 100644 index 0000000000..229b634abd --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/scope/DaprAgenticScopeStore.java @@ -0,0 +1,159 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.scope; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.langchain4j.agentic.scope.AgenticScopeKey; +import dev.langchain4j.agentic.scope.AgenticScopeSerializer; +import dev.langchain4j.agentic.scope.AgenticScopeStore; +import dev.langchain4j.agentic.scope.DefaultAgenticScope; +import io.dapr.client.DaprClient; +import io.dapr.client.domain.State; +import org.jboss.logging.Logger; + +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Optional; +import java.util.Set; + +/** + * An {@link AgenticScopeStore} backed by Dapr's key-value state store — the LangChain4j + * equivalent of a LangGraph checkpointer. + * + *

When registered via {@link dev.langchain4j.agentic.scope.AgenticScopePersister}, + * LangChain4j's {@code AgenticScopeRegistry} persists every agentic scope (shared state, + * conversation context, and agent invocations of a multi-agent workflow) on each update, + * so agentic state survives process restarts and is shareable across replicas. + * + *

Scopes are serialized with LangChain4j's own {@link AgenticScopeSerializer} and stored + * under {@code agenticscope||||}. A companion index key + * ({@code agenticscope||_index}) tracks all stored keys to support {@link #getAllKeys()}. + * + *

Note: index updates are synchronized within this instance; concurrent writers in other + * replicas may race on the index (the scope entries themselves are never lost — only the + * listing may briefly miss entries). Keys reconstructed from the index carry the + * {@code memoryId} as a string. + */ +public class DaprAgenticScopeStore implements AgenticScopeStore { + + private static final Logger LOG = Logger.getLogger(DaprAgenticScopeStore.class); + + private static final String KEY_PREFIX = "agenticscope||"; + private static final String INDEX_KEY = KEY_PREFIX + "_index"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final DaprClient daprClient; + private final String stateStoreName; + + /** + * Creates a new DaprAgenticScopeStore. + * + * @param daprClient the Dapr client + * @param stateStoreName the state store component name + */ + public DaprAgenticScopeStore(DaprClient daprClient, String stateStoreName) { + this.daprClient = daprClient; + this.stateStoreName = stateStoreName; + } + + @Override + public boolean save(AgenticScopeKey key, DefaultAgenticScope agenticScope) { + try { + String json = AgenticScopeSerializer.toJson(agenticScope); + daprClient.saveState(stateStoreName, stateKey(key), json).block(); + updateIndex(index -> index.add(indexEntry(key))); + LOG.debugf("Saved agentic scope %s (%d bytes)", stateKey(key), json.length()); + return true; + } catch (Exception e) { + LOG.errorf("Failed to save agentic scope %s: %s", stateKey(key), e.getMessage()); + return false; + } + } + + @Override + public Optional load(AgenticScopeKey key) { + try { + State state = daprClient + .getState(stateStoreName, stateKey(key), String.class).block(); + if (state == null || state.getValue() == null || state.getValue().isEmpty()) { + return Optional.empty(); + } + return Optional.of(AgenticScopeSerializer.fromJson(state.getValue())); + } catch (Exception e) { + LOG.errorf("Failed to load agentic scope %s: %s", stateKey(key), e.getMessage()); + return Optional.empty(); + } + } + + @Override + public boolean delete(AgenticScopeKey key) { + try { + boolean existed = load(key).isPresent(); + daprClient.deleteState(stateStoreName, stateKey(key)).block(); + updateIndex(index -> index.remove(indexEntry(key))); + return existed; + } catch (Exception e) { + LOG.errorf("Failed to delete agentic scope %s: %s", stateKey(key), e.getMessage()); + return false; + } + } + + @Override + public Set getAllKeys() { + Set keys = new HashSet<>(); + for (String entry : readIndex()) { + int separator = entry.indexOf("||"); + if (separator > 0) { + keys.add(new AgenticScopeKey( + entry.substring(0, separator), entry.substring(separator + 2))); + } + } + return keys; + } + + private static String stateKey(AgenticScopeKey key) { + return KEY_PREFIX + indexEntry(key); + } + + private static String indexEntry(AgenticScopeKey key) { + return key.agentId() + "||" + key.memoryId(); + } + + private Set readIndex() { + try { + State state = daprClient + .getState(stateStoreName, INDEX_KEY, String.class).block(); + if (state == null || state.getValue() == null || state.getValue().isEmpty()) { + return new LinkedHashSet<>(); + } + return MAPPER.readValue(state.getValue(), new TypeReference>() { + }); + } catch (Exception e) { + LOG.warnf("Failed to read agentic scope index: %s", e.getMessage()); + return new LinkedHashSet<>(); + } + } + + private synchronized void updateIndex(java.util.function.Consumer> mutation) { + try { + Set index = readIndex(); + mutation.accept(index); + daprClient.saveState(stateStoreName, INDEX_KEY, + MAPPER.writeValueAsString(index)).block(); + } catch (Exception e) { + LOG.warnf("Failed to update agentic scope index: %s", e.getMessage()); + } + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowRuntimeRecorder.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowRuntimeRecorder.java new file mode 100644 index 0000000000..2b08d7e697 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowRuntimeRecorder.java @@ -0,0 +1,114 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.workflow; + +import io.dapr.quarkus.langchain4j.agent.recovery.AgentToolClassRegistry; +import io.dapr.workflows.Workflow; +import io.dapr.workflows.WorkflowActivity; +import io.dapr.workflows.runtime.WorkflowRuntime; +import io.dapr.workflows.runtime.WorkflowRuntimeBuilder; +import io.quarkus.runtime.RuntimeValue; +import io.quarkus.runtime.annotations.Recorder; +import org.jboss.logging.Logger; + +import java.util.List; + +/** + * Quarkus recorder that registers Dapr workflows and activities at runtime + * using the Dapr Java SDK's {@link WorkflowRuntimeBuilder} directly. + * + *

This replaces quarkus-dapr's build-time {@code WorkflowItemBuildItem} pipeline, + * giving full control over workflow naming (same class registered under multiple + * names) and avoiding the deduplication-by-class limitation. + */ +@Recorder +public class DaprWorkflowRuntimeRecorder { + + private static final Logger LOG = Logger.getLogger(DaprWorkflowRuntimeRecorder.class); + + /** + * Creates a new {@link WorkflowRuntimeBuilder}. + * + * @return a runtime value wrapping the builder + */ + public RuntimeValue createBuilder() { + return new RuntimeValue<>(new WorkflowRuntimeBuilder()); + } + + /** + * Registers a workflow class under a custom name. + * + * @param builder the builder runtime value + * @param name the workflow registration name + * @param className the fully-qualified workflow class name + */ + @SuppressWarnings("unchecked") + public void registerWorkflow(RuntimeValue builder, + String name, String className) { + try { + Class clazz = Thread.currentThread().getContextClassLoader().loadClass(className); + builder.getValue().registerWorkflow(name, (Class) clazz); + LOG.infof("Registered workflow: %s -> %s", name, clazz.getSimpleName()); + } catch (ClassNotFoundException e) { + throw new RuntimeException("Workflow class not found: " + className, e); + } + } + + /** + * Registers an activity class under a custom name. + * + * @param builder the builder runtime value + * @param name the activity registration name + * @param className the fully-qualified activity class name + */ + @SuppressWarnings("unchecked") + public void registerActivity(RuntimeValue builder, + String name, String className) { + try { + Class clazz = Thread.currentThread().getContextClassLoader().loadClass(className); + builder.getValue().registerActivity(name, (Class) clazz); + LOG.infof("Registered activity: %s -> %s", name, clazz.getSimpleName()); + } catch (ClassNotFoundException e) { + throw new RuntimeException("Activity class not found: " + className, e); + } + } + + /** + * Registers the tool class names for an agent (from {@code @ToolBox} annotations). + * Used by the durable agent-llm/agent-tool activities to resolve which tools the agent + * can call. + * + * @param agentName the agent name from {@code @Agent(name=...)} + * @param toolClassNames fully-qualified class names from {@code @ToolBox} + */ + public void registerAgentToolClasses(String agentName, List toolClassNames) { + AgentToolClassRegistry.register(agentName, toolClassNames); + LOG.infof("Registered agent tool classes: %s -> %s", agentName, toolClassNames); + } + + /** + * Builds the workflow runtime and starts it (non-blocking). + * + * @param builder the builder runtime value + */ + public void startRuntime(RuntimeValue builder) { + WorkflowRuntime runtime = builder.getValue().build(); + runtime.start(false); + LOG.info("Dapr Workflow runtime started"); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + LOG.info("Shutting down Dapr Workflow runtime"); + runtime.close(); + })); + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 0000000000..77ee543871 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,11 @@ +name: LangChain4j Dapr Agentic +description: Run LangChain4j agents as durable Dapr Workflows +artifact: ${project.groupId}:${project.artifactId}:${project.version} +metadata: + keywords: + - ai + - agentic + - cncf + - dapr + categories: + - "integration" \ No newline at end of file diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/test/java/io/dapr/quarkus/langchain4j/memory/KeyValueChatMemoryStoreTest.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/test/java/io/dapr/quarkus/langchain4j/memory/KeyValueChatMemoryStoreTest.java new file mode 100644 index 0000000000..59b847ae65 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/test/java/io/dapr/quarkus/langchain4j/memory/KeyValueChatMemoryStoreTest.java @@ -0,0 +1,195 @@ +package io.dapr.quarkus.langchain4j.memory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import io.dapr.client.DaprClient; +import io.dapr.client.domain.State; +import reactor.core.publisher.Mono; + +class KeyValueChatMemoryStoreTest { + + private static final String STATE_STORE_NAME = "statestore"; + + private DaprClient daprClient; + private KeyValueChatMemoryStore store; + + /** + * Simple serializer for testing: stores the message type and text, one per line. + * Format: "TYPE:text\nTYPE:text\n..." + */ + private static final Function, String> TEST_SERIALIZER = messages -> { + StringBuilder sb = new StringBuilder(); + for (ChatMessage msg : messages) { + sb.append(msg.type().name()).append(":"); + switch (msg.type()) { + case SYSTEM -> sb.append(((SystemMessage) msg).text()); + case USER -> sb.append(((UserMessage) msg).singleText()); + case AI -> sb.append(((AiMessage) msg).text()); + default -> sb.append("unknown"); + } + sb.append("\n"); + } + return sb.toString(); + }; + + private static final Function> TEST_DESERIALIZER = json -> { + List messages = new ArrayList<>(); + for (String line : json.split("\n")) { + if (line.isEmpty()) + continue; + String[] parts = line.split(":", 2); + String type = parts[0]; + String text = parts[1]; + switch (type) { + case "SYSTEM" -> messages.add(new SystemMessage(text)); + case "USER" -> messages.add(new UserMessage(text)); + case "AI" -> messages.add(new AiMessage(text)); + } + } + return messages; + }; + + @BeforeEach + void setUp() { + daprClient = mock(DaprClient.class); + store = new KeyValueChatMemoryStore(daprClient, STATE_STORE_NAME, + TEST_SERIALIZER, TEST_DESERIALIZER); + } + + @Test + void getMessagesShouldReturnEmptyListWhenKeyDoesNotExist() { + State emptyState = new State<>("user-1", (String) null, (String) null); + when(daprClient.getState(eq(STATE_STORE_NAME), eq("user-1"), eq(String.class))) + .thenReturn(Mono.just(emptyState)); + + List messages = store.getMessages("user-1"); + + assertThat(messages).isEmpty(); + verify(daprClient).getState(STATE_STORE_NAME, "user-1", String.class); + } + + @Test + void getMessagesShouldReturnEmptyListWhenValueIsEmpty() { + State emptyState = new State<>("user-1", "", (String) null); + when(daprClient.getState(eq(STATE_STORE_NAME), eq("user-1"), eq(String.class))) + .thenReturn(Mono.just(emptyState)); + + List messages = store.getMessages("user-1"); + + assertThat(messages).isEmpty(); + } + + @Test + void updateMessagesShouldSaveSerializedMessages() { + when(daprClient.saveState(eq(STATE_STORE_NAME), eq("user-1"), any(String.class))) + .thenReturn(Mono.empty()); + + List messages = List.of( + new UserMessage("Hello"), + new AiMessage("Hi there!")); + + store.updateMessages("user-1", messages); + + ArgumentCaptor jsonCaptor = ArgumentCaptor.forClass(String.class); + verify(daprClient).saveState(eq(STATE_STORE_NAME), eq("user-1"), jsonCaptor.capture()); + + String savedJson = jsonCaptor.getValue(); + assertThat(savedJson).contains("USER:Hello"); + assertThat(savedJson).contains("AI:Hi there!"); + } + + @Test + void getMessagesShouldReturnDeserializedMessages() { + String stored = "SYSTEM:You are a helpful assistant\nUSER:What is Java?\nAI:A programming language.\n"; + State state = new State<>("conv-42", stored, (String) null); + when(daprClient.getState(eq(STATE_STORE_NAME), eq("conv-42"), eq(String.class))) + .thenReturn(Mono.just(state)); + + List retrieved = store.getMessages("conv-42"); + + assertThat(retrieved).hasSize(3); + assertThat(retrieved.get(0)).isInstanceOf(SystemMessage.class); + assertThat(((SystemMessage) retrieved.get(0)).text()).isEqualTo("You are a helpful assistant"); + assertThat(retrieved.get(1)).isInstanceOf(UserMessage.class); + assertThat(((UserMessage) retrieved.get(1)).singleText()).isEqualTo("What is Java?"); + assertThat(retrieved.get(2)).isInstanceOf(AiMessage.class); + assertThat(((AiMessage) retrieved.get(2)).text()).isEqualTo("A programming language."); + } + + @Test + void deleteMessagesShouldRemoveState() { + when(daprClient.deleteState(eq(STATE_STORE_NAME), eq("user-1"))) + .thenReturn(Mono.empty()); + + store.deleteMessages("user-1"); + + verify(daprClient).deleteState(STATE_STORE_NAME, "user-1"); + } + + @Test + void shouldUseMemoryIdToStringAsKey() { + when(daprClient.saveState(eq(STATE_STORE_NAME), eq("123"), any(String.class))) + .thenReturn(Mono.empty()); + + store.updateMessages(123, List.of(new UserMessage("test"))); + + verify(daprClient).saveState(eq(STATE_STORE_NAME), eq("123"), any(String.class)); + } + + @Test + void updateMessagesShouldHandleEmptyList() { + when(daprClient.saveState(eq(STATE_STORE_NAME), eq("user-1"), any(String.class))) + .thenReturn(Mono.empty()); + + store.updateMessages("user-1", List.of()); + + verify(daprClient).saveState(eq(STATE_STORE_NAME), eq("user-1"), any(String.class)); + } + + @Test + void roundTripShouldPreserveMessages() { + // Save messages + when(daprClient.saveState(eq(STATE_STORE_NAME), eq("rt-1"), any(String.class))) + .thenReturn(Mono.empty()); + + List original = List.of( + new SystemMessage("Be concise"), + new UserMessage("Hello"), + new AiMessage("Hi!")); + + store.updateMessages("rt-1", original); + + // Capture what was saved + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(daprClient).saveState(eq(STATE_STORE_NAME), eq("rt-1"), captor.capture()); + + // Now simulate reading it back + State state = new State<>("rt-1", captor.getValue(), (String) null); + when(daprClient.getState(eq(STATE_STORE_NAME), eq("rt-1"), eq(String.class))) + .thenReturn(Mono.just(state)); + + List retrieved = store.getMessages("rt-1"); + + assertThat(retrieved).hasSize(3); + assertThat(((SystemMessage) retrieved.get(0)).text()).isEqualTo("Be concise"); + assertThat(((UserMessage) retrieved.get(1)).singleText()).isEqualTo("Hello"); + assertThat(((AiMessage) retrieved.get(2)).text()).isEqualTo("Hi!"); + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/test/java/io/dapr/quarkus/langchain4j/scope/DaprAgenticScopeStoreTest.java b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/test/java/io/dapr/quarkus/langchain4j/scope/DaprAgenticScopeStoreTest.java new file mode 100644 index 0000000000..f0e498b869 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-agentic/runtime/src/test/java/io/dapr/quarkus/langchain4j/scope/DaprAgenticScopeStoreTest.java @@ -0,0 +1,115 @@ +package io.dapr.quarkus.langchain4j.scope; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import dev.langchain4j.agentic.scope.AgenticScopeKey; +import dev.langchain4j.agentic.scope.AgenticScopeSerializer; +import dev.langchain4j.agentic.scope.DefaultAgenticScope; +import io.dapr.client.DaprClient; +import io.dapr.client.domain.State; +import reactor.core.publisher.Mono; + +class DaprAgenticScopeStoreTest { + + private static final String STATE_STORE_NAME = "statestore"; + private static final String SCOPE_KEY = "agenticscope||agent-1||memory-1"; + private static final String INDEX_KEY = "agenticscope||_index"; + + private DaprClient daprClient; + private DaprAgenticScopeStore store; + + @BeforeEach + void setUp() { + daprClient = mock(DaprClient.class); + store = new DaprAgenticScopeStore(daprClient, STATE_STORE_NAME); + // Default: empty state for any key + when(daprClient.getState(eq(STATE_STORE_NAME), anyString(), eq(String.class))) + .thenReturn(Mono.just(new State<>("any", (String) null, (String) null))); + when(daprClient.saveState(eq(STATE_STORE_NAME), anyString(), anyString())) + .thenReturn(Mono.empty()); + when(daprClient.deleteState(eq(STATE_STORE_NAME), anyString())) + .thenReturn(Mono.empty()); + } + + private static DefaultAgenticScope scope(String memoryId) { + return AgenticScopeSerializer.fromJson( + "{\"memoryId\":\"" + memoryId + "\",\"kind\":\"PERSISTENT\"}"); + } + + @Test + void saveShouldSerializeScopeAndUpdateIndex() { + boolean result = store.save(new AgenticScopeKey("agent-1", "memory-1"), scope("memory-1")); + + assertThat(result).isTrue(); + ArgumentCaptor valueCaptor = ArgumentCaptor.forClass(String.class); + verify(daprClient).saveState(eq(STATE_STORE_NAME), eq(SCOPE_KEY), valueCaptor.capture()); + assertThat(valueCaptor.getValue()).contains("memory-1"); + // Index updated with the new entry + verify(daprClient).saveState(eq(STATE_STORE_NAME), eq(INDEX_KEY), + eq("[\"agent-1||memory-1\"]")); + } + + @Test + void loadShouldReturnEmptyWhenKeyDoesNotExist() { + Optional loaded = store.load(new AgenticScopeKey("agent-1", "memory-1")); + + assertThat(loaded).isEmpty(); + } + + @Test + void loadShouldRoundTripSavedScope() { + String json = AgenticScopeSerializer.toJson(scope("memory-1")); + when(daprClient.getState(eq(STATE_STORE_NAME), eq(SCOPE_KEY), eq(String.class))) + .thenReturn(Mono.just(new State<>(SCOPE_KEY, json, (String) null))); + + Optional loaded = store.load(new AgenticScopeKey("agent-1", "memory-1")); + + assertThat(loaded).isPresent(); + assertThat(loaded.get().memoryId()).isEqualTo("memory-1"); + } + + @Test + void deleteShouldRemoveStateAndIndexEntry() { + String json = AgenticScopeSerializer.toJson(scope("memory-1")); + when(daprClient.getState(eq(STATE_STORE_NAME), eq(SCOPE_KEY), eq(String.class))) + .thenReturn(Mono.just(new State<>(SCOPE_KEY, json, (String) null))); + when(daprClient.getState(eq(STATE_STORE_NAME), eq(INDEX_KEY), eq(String.class))) + .thenReturn(Mono.just(new State<>(INDEX_KEY, "[\"agent-1||memory-1\"]", (String) null))); + + boolean result = store.delete(new AgenticScopeKey("agent-1", "memory-1")); + + assertThat(result).isTrue(); + verify(daprClient).deleteState(STATE_STORE_NAME, SCOPE_KEY); + verify(daprClient).saveState(eq(STATE_STORE_NAME), eq(INDEX_KEY), eq("[]")); + } + + @Test + void getAllKeysShouldParseIndexEntries() { + when(daprClient.getState(eq(STATE_STORE_NAME), eq(INDEX_KEY), eq(String.class))) + .thenReturn(Mono.just(new State<>(INDEX_KEY, + "[\"agent-1||memory-1\",\"agent-2||memory-2\"]", (String) null))); + + Set keys = store.getAllKeys(); + + assertThat(keys).containsExactlyInAnyOrder( + new AgenticScopeKey("agent-1", "memory-1"), + new AgenticScopeKey("agent-2", "memory-2")); + } + + @Test + void getAllKeysShouldReturnEmptySetWhenIndexMissing() { + assertThat(store.getAllKeys()).isEmpty(); + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-llm/deployment/pom.xml b/quarkus/quarkus-langchain4j-dapr-llm/deployment/pom.xml new file mode 100644 index 0000000000..c941ed8423 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-llm/deployment/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + io.dapr.quarkus + quarkus-langchain4j-dapr-llm-parent + 0.1.0-SNAPSHOT + ../pom.xml + + + quarkus-langchain4j-dapr-llm-deployment + Quarkus LangChain4j Dapr LLM - Deployment + + + + io.dapr.quarkus + quarkus-langchain4j-dapr-llm + ${project.version} + + + io.quarkiverse.langchain4j + quarkus-langchain4j-core-deployment + ${quarkus-langchain4j.version} + + + io.quarkus + quarkus-arc-deployment + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + diff --git a/quarkus/quarkus-langchain4j-dapr-llm/deployment/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/deployment/DaprConversationProcessor.java b/quarkus/quarkus-langchain4j-dapr-llm/deployment/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/deployment/DaprConversationProcessor.java new file mode 100644 index 0000000000..ffb7139d85 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-llm/deployment/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/deployment/DaprConversationProcessor.java @@ -0,0 +1,71 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.chatmodel.deployment; + +import dev.langchain4j.model.chat.ChatModel; +import io.dapr.quarkus.langchain4j.chatmodel.DaprConversationConfig; +import io.dapr.quarkus.langchain4j.chatmodel.DaprConversationRecorder; +import io.quarkiverse.langchain4j.deployment.items.ChatModelProviderCandidateBuildItem; +import io.quarkiverse.langchain4j.deployment.items.SelectedChatModelProviderBuildItem; +import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.FeatureBuildItem; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.List; + +/** + * Quarkus deployment processor for the Dapr Conversation ChatModel provider. + */ +public class DaprConversationProcessor { + + private static final String FEATURE = "langchain4j-dapr-conversation"; + private static final String PROVIDER = "dapr-conversation"; + + @BuildStep + FeatureBuildItem feature() { + return new FeatureBuildItem(FEATURE); + } + + @BuildStep + void providerCandidates(BuildProducer candidates) { + candidates.produce(new ChatModelProviderCandidateBuildItem(PROVIDER)); + } + + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + void generateBeans(DaprConversationRecorder recorder, + List selectedProviders, + BuildProducer syntheticBeans) { + + for (SelectedChatModelProviderBuildItem selected : selectedProviders) { + if (!PROVIDER.equals(selected.getProvider())) { + continue; + } + + SyntheticBeanBuildItem.ExtendedBeanConfigurator configurator = + SyntheticBeanBuildItem.configure(ChatModel.class) + .scope(ApplicationScoped.class) + .setRuntimeInit() + .defaultBean() + .unremovable() + .createWith(recorder.chatModel()); + + syntheticBeans.produce(configurator.done()); + } + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-llm/pom.xml b/quarkus/quarkus-langchain4j-dapr-llm/pom.xml new file mode 100644 index 0000000000..d122ebadf4 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-llm/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + + io.dapr.quarkus + dapr-quarkus-agentic-parent + 0.1.0-SNAPSHOT + ../pom.xml + + + quarkus-langchain4j-dapr-llm-parent + pom + Quarkus LangChain4j Dapr LLM - Parent + + + runtime + deployment + + diff --git a/quarkus/quarkus-langchain4j-dapr-llm/runtime/pom.xml b/quarkus/quarkus-langchain4j-dapr-llm/runtime/pom.xml new file mode 100644 index 0000000000..7bbbb1ddd3 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-llm/runtime/pom.xml @@ -0,0 +1,81 @@ + + + 4.0.0 + + io.dapr.quarkus + quarkus-langchain4j-dapr-llm-parent + 0.1.0-SNAPSHOT + ../pom.xml + + + quarkus-langchain4j-dapr-llm + Quarkus LangChain4j Dapr LLM - Runtime + + + + io.quarkiverse.langchain4j + quarkus-langchain4j-core + ${quarkus-langchain4j.version} + + + io.quarkiverse.dapr + quarkus-dapr + ${quarkus-dapr.version} + + + io.dapr + dapr-sdk + ${dapr.sdk.version} + + + io.quarkus + quarkus-arc + + + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + 5.14.2 + test + + + org.assertj + assertj-core + 3.26.3 + test + + + + + + + src/main/resources + true + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + diff --git a/quarkus/quarkus-langchain4j-dapr-llm/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationChatModel.java b/quarkus/quarkus-langchain4j-dapr-llm/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationChatModel.java new file mode 100644 index 0000000000..962a6bf5f1 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-llm/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationChatModel.java @@ -0,0 +1,201 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.chatmodel; + +import dev.langchain4j.agent.tool.ToolSpecification; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.ToolExecutionResultMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.internal.JsonSchemaElementUtils; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import io.dapr.client.DaprPreviewClient; +import io.dapr.client.domain.ConversationInputAlpha2; +import io.dapr.client.domain.ConversationMessage; +import io.dapr.client.domain.ConversationMessageContent; +import io.dapr.client.domain.ConversationRequestAlpha2; +import io.dapr.client.domain.ConversationResponseAlpha2; +import io.dapr.client.domain.ConversationResultChoices; +import io.dapr.client.domain.ConversationResultMessage; +import io.dapr.client.domain.ConversationToolCalls; +import io.dapr.client.domain.ConversationToolCallsOfFunction; +import io.dapr.client.domain.ConversationTools; +import io.dapr.client.domain.ConversationToolsFunction; +import org.jboss.logging.Logger; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * LangChain4j {@link ChatModel} implementation backed by the Dapr Conversation API. + * + *

This allows swapping LLM providers (OpenAI, Anthropic, etc.) by changing the + * Dapr component configuration — no Java code changes needed. + */ +public class DaprConversationChatModel implements ChatModel { + + private static final Logger LOG = Logger.getLogger(DaprConversationChatModel.class); + + private final DaprPreviewClient client; + private final String componentName; + private final double temperature; + + /** + * Creates a new DaprConversationChatModel. + * + * @param client the Dapr preview client + * @param componentName the Dapr conversation component name + * @param temperature the temperature for generation + */ + public DaprConversationChatModel(DaprPreviewClient client, + String componentName, double temperature) { + this.client = client; + this.componentName = componentName; + this.temperature = temperature; + } + + @Override + public ChatResponse chat(ChatRequest chatRequest) { + List daprMessages = new ArrayList<>(); + + // Convert LangChain4j messages to Dapr messages + for (ChatMessage msg : chatRequest.messages()) { + daprMessages.add(toDaprMessage(msg)); + } + + ConversationInputAlpha2 input = new ConversationInputAlpha2(daprMessages); + + ConversationRequestAlpha2 request = new ConversationRequestAlpha2( + componentName, List.of(input)); + request.setTemperature(temperature); + + // Convert tool specifications if present + if (chatRequest.toolSpecifications() != null && !chatRequest.toolSpecifications().isEmpty()) { + List tools = new ArrayList<>(); + for (ToolSpecification spec : chatRequest.toolSpecifications()) { + tools.add(toDaprTool(spec)); + } + request.setTools(tools); + request.setToolChoice("auto"); + } + + LOG.debugf("Sending conversation request to Dapr component '%s' with %d messages", + componentName, daprMessages.size()); + + ConversationResponseAlpha2 response = client.converseAlpha2(request).block(); + + return fromDaprResponse(response); + } + + private ConversationMessage toDaprMessage(ChatMessage msg) { + if (msg instanceof SystemMessage sm) { + return new io.dapr.client.domain.SystemMessage( + List.of(new ConversationMessageContent(sm.text()))); + } + if (msg instanceof UserMessage um) { + return new io.dapr.client.domain.UserMessage( + List.of(new ConversationMessageContent(um.singleText()))); + } + if (msg instanceof AiMessage ai) { + // Always non-null: AssistantMessage's constructor calls List.copyOf(toolCalls), + // which NPEs on null. A tool-less AiMessage (e.g. a prior iteration's plain-text + // answer carried into a @LoopAgent's follow-up history) must map cleanly. An empty + // list is safe on the wire: the alpha2 proto's tool_calls is a repeated field, so + // zero elements serialize identically to "unset" — daprd cannot distinguish empty + // from absent, so no tool_calls:[] is ever sent to the provider. + List toolCalls = new ArrayList<>(); + if (ai.hasToolExecutionRequests()) { + for (var req : ai.toolExecutionRequests()) { + ConversationToolCalls tc = new ConversationToolCalls( + new ConversationToolCallsOfFunction(req.name(), req.arguments())); + tc.setId(req.id()); + toolCalls.add(tc); + } + } + String text = ai.text() != null ? ai.text() : ""; + return new io.dapr.client.domain.AssistantMessage( + List.of(new ConversationMessageContent(text)), toolCalls); + } + if (msg instanceof ToolExecutionResultMessage tr) { + io.dapr.client.domain.ToolMessage tm = new io.dapr.client.domain.ToolMessage( + List.of(new ConversationMessageContent(tr.text()))); + tm.setToolId(tr.id()); + return tm; + } + throw new IllegalArgumentException("Unsupported message type: " + msg.getClass()); + } + + private ConversationTools toDaprTool(ToolSpecification spec) { + Map parameters; + if (spec.parameters() != null) { + // Convert the LangChain4j JSON schema (properties, required, ...) so the + // model knows the tool's argument shape + parameters = JsonSchemaElementUtils.toMap(spec.parameters()); + } else { + // Parameterless tool: advertise an empty object schema + parameters = new HashMap<>(); + parameters.put("type", "object"); + } + ConversationToolsFunction fn = new ConversationToolsFunction(spec.name(), parameters); + if (spec.description() != null) { + fn.setDescription(spec.description()); + } + return new ConversationTools(fn); + } + + private ChatResponse fromDaprResponse(ConversationResponseAlpha2 response) { + if (response == null || response.getOutputs() == null || response.getOutputs().isEmpty()) { + return ChatResponse.builder() + .aiMessage(AiMessage.from("")) + .build(); + } + + var result = response.getOutputs().get(0); + if (result.getChoices() == null || result.getChoices().isEmpty()) { + return ChatResponse.builder() + .aiMessage(AiMessage.from("")) + .build(); + } + + ConversationResultChoices choice = result.getChoices().get(0); + ConversationResultMessage message = choice.getMessage(); + + // Check for tool calls + if (message != null && message.hasToolCalls()) { + List requests = new ArrayList<>(); + for (ConversationToolCalls tc : message.getToolCalls()) { + requests.add(dev.langchain4j.agent.tool.ToolExecutionRequest.builder() + .id(tc.getId()) + .name(tc.getFunction().getName()) + .arguments(tc.getFunction().getArguments()) + .build()); + } + return ChatResponse.builder() + .aiMessage(AiMessage.from(requests)) + .build(); + } + + // Text response + String content = message != null && message.getContent() != null + ? message.getContent() : ""; + return ChatResponse.builder() + .aiMessage(AiMessage.from(content)) + .build(); + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-llm/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationConfig.java b/quarkus/quarkus-langchain4j-dapr-llm/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationConfig.java new file mode 100644 index 0000000000..7cf3a58446 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-llm/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationConfig.java @@ -0,0 +1,53 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.chatmodel; + +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +import java.util.Optional; + +/** + * Runtime configuration for the Dapr Conversation ChatModel provider. + */ +@ConfigRoot(phase = ConfigPhase.RUN_TIME) +@ConfigMapping(prefix = "quarkus.langchain4j.dapr") +public interface DaprConversationConfig { + + /** + * The Dapr conversation component name. + * + * @return the component name + */ + @WithDefault("llm") + String componentName(); + + /** + * The temperature for generation (0.0 - 2.0). + * + * @return the temperature + */ + @WithDefault("0.7") + double temperature(); + + /** + * Whether the Dapr conversation integration is enabled. + * + * @return true if enabled + */ + @WithDefault("true") + boolean enabled(); +} diff --git a/quarkus/quarkus-langchain4j-dapr-llm/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationRecorder.java b/quarkus/quarkus-langchain4j-dapr-llm/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationRecorder.java new file mode 100644 index 0000000000..9bad2c46d7 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-llm/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationRecorder.java @@ -0,0 +1,60 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.chatmodel; + +import dev.langchain4j.model.chat.ChatModel; +import io.dapr.client.DaprClientBuilder; +import io.dapr.client.DaprPreviewClient; +import io.quarkus.arc.SyntheticCreationalContext; +import io.quarkus.runtime.RuntimeValue; +import io.quarkus.runtime.annotations.Recorder; +import org.jboss.logging.Logger; + +import java.util.function.Function; + +/** + * Quarkus recorder that produces {@link DaprConversationChatModel} instances. + */ +@Recorder +public class DaprConversationRecorder { + + private static final Logger LOG = Logger.getLogger(DaprConversationRecorder.class); + + private final RuntimeValue config; + + /** + * Creates the recorder with runtime configuration. + * + * @param config the runtime configuration wrapped as RuntimeValue + */ + public DaprConversationRecorder(RuntimeValue config) { + this.config = config; + } + + /** + * Returns a function that creates a {@link DaprConversationChatModel}. + * + * @return a function that creates the ChatModel from CDI context + */ + public Function, ChatModel> chatModel() { + return ctx -> { + DaprConversationConfig cfg = config.getValue(); + DaprPreviewClient client = new DaprClientBuilder().buildPreviewClient(); + LOG.infof("DaprConversationChatModel created — component=%s, temperature=%.1f", + cfg.componentName(), cfg.temperature()); + return new DaprConversationChatModel( + client, cfg.componentName(), cfg.temperature()); + }; + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-llm/runtime/src/main/resources/META-INF/quarkus-extension.properties b/quarkus/quarkus-langchain4j-dapr-llm/runtime/src/main/resources/META-INF/quarkus-extension.properties new file mode 100644 index 0000000000..8b8f322b6d --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-llm/runtime/src/main/resources/META-INF/quarkus-extension.properties @@ -0,0 +1 @@ +deployment-artifact=io.dapr.quarkus\:quarkus-langchain4j-dapr-llm-deployment\:0.1.0-SNAPSHOT diff --git a/quarkus/quarkus-langchain4j-dapr-llm/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/quarkus/quarkus-langchain4j-dapr-llm/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 0000000000..b3fc146ff5 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-llm/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,11 @@ +name: Quarkus LangChain4j Dapr Conversation +description: LangChain4j ChatModel backed by Dapr Conversation API +artifact: ${project.groupId}:${project.artifactId}:${project.version} +metadata: + keywords: + - langchain4j + - dapr + - conversation + - llm + categories: + - "AI" diff --git a/quarkus/quarkus-langchain4j-dapr-llm/runtime/src/test/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationChatModelTest.java b/quarkus/quarkus-langchain4j-dapr-llm/runtime/src/test/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationChatModelTest.java new file mode 100644 index 0000000000..e5e5b2efdd --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-llm/runtime/src/test/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationChatModelTest.java @@ -0,0 +1,147 @@ +package io.dapr.quarkus.langchain4j.chatmodel; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import dev.langchain4j.agent.tool.ToolExecutionRequest; +import dev.langchain4j.agent.tool.ToolSpecification; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.request.json.JsonObjectSchema; +import io.dapr.client.domain.AssistantMessage; +import io.dapr.client.DaprPreviewClient; +import io.dapr.client.domain.ConversationMessage; +import io.dapr.client.domain.ConversationRequestAlpha2; +import io.dapr.client.domain.ConversationToolCalls; +import io.dapr.client.domain.ConversationToolsFunction; +import reactor.core.publisher.Mono; + +class DaprConversationChatModelTest { + + private DaprPreviewClient client; + private DaprConversationChatModel model; + + @BeforeEach + void setUp() { + client = mock(DaprPreviewClient.class); + when(client.converseAlpha2(any())).thenReturn(Mono.empty()); + model = new DaprConversationChatModel(client, "llm", 0.7); + } + + @Test + void toolParametersShouldCarryJsonSchema() { + ToolSpecification spec = ToolSpecification.builder() + .name("getWeather") + .description("Get the weather for a city") + .parameters(JsonObjectSchema.builder() + .addStringProperty("city", "The city name") + .required("city") + .build()) + .build(); + + model.chat(ChatRequest.builder() + .messages(UserMessage.from("What is the weather in Madrid?")) + .toolSpecifications(spec) + .build()); + + ConversationToolsFunction fn = capturedRequest().getTools().get(0).getFunction(); + assertThat(fn.getName()).isEqualTo("getWeather"); + assertThat(fn.getDescription()).isEqualTo("Get the weather for a city"); + + Map parameters = fn.getParameters(); + assertThat(parameters.get("type")).isEqualTo("object"); + + @SuppressWarnings("unchecked") + Map properties = (Map) parameters.get("properties"); + assertThat(properties).containsKey("city"); + + @SuppressWarnings("unchecked") + List required = (List) parameters.get("required"); + assertThat(required).containsExactly("city"); + } + + @Test + void parameterlessToolShouldFallBackToEmptyObjectSchema() { + ToolSpecification spec = ToolSpecification.builder() + .name("ping") + .build(); + + model.chat(ChatRequest.builder() + .messages(UserMessage.from("ping")) + .toolSpecifications(spec) + .build()); + + Map parameters = + capturedRequest().getTools().get(0).getFunction().getParameters(); + assertThat(parameters).containsExactly(entry("type", "object")); + } + + @Test + void toolLessAiMessageShouldMapWithoutThrowingAndCarryNoToolCalls() { + // Regression: a plain-text AiMessage (no tool requests) — e.g. a prior @LoopAgent + // iteration's final answer carried into the next iteration's history — used to NPE + // in AssistantMessage's List.copyOf(null). + model.chat(ChatRequest.builder() + .messages( + UserMessage.from("Plan my trip"), + AiMessage.from("Here is your itinerary.")) + .build()); + + AssistantMessage assistant = (AssistantMessage) messageOfRole( + capturedRequest(), io.dapr.client.domain.ConversationMessageRole.ASSISTANT); + assertThat(assistant.getContent().get(0).getText()).isEqualTo("Here is your itinerary."); + // Non-null (so the SDK constructor does not NPE) and empty (so no tool_calls are + // serialized — an empty repeated proto field is wire-identical to unset). + assertThat(assistant.getToolCalls()).isNotNull().isEmpty(); + } + + @Test + void aiMessageWithToolRequestsShouldMapToolCalls() { + AiMessage withTool = AiMessage.from(ToolExecutionRequest.builder() + .id("call-1") + .name("getWeather") + .arguments("{\"city\":\"Madrid\"}") + .build()); + + model.chat(ChatRequest.builder() + .messages( + UserMessage.from("Weather in Madrid?"), + withTool) + .build()); + + AssistantMessage assistant = (AssistantMessage) messageOfRole( + capturedRequest(), io.dapr.client.domain.ConversationMessageRole.ASSISTANT); + assertThat(assistant.getToolCalls()).hasSize(1); + ConversationToolCalls tc = assistant.getToolCalls().get(0); + assertThat(tc.getId()).isEqualTo("call-1"); + assertThat(tc.getFunction().getName()).isEqualTo("getWeather"); + assertThat(tc.getFunction().getArguments()).isEqualTo("{\"city\":\"Madrid\"}"); + } + + private static ConversationMessage messageOfRole( + ConversationRequestAlpha2 request, io.dapr.client.domain.ConversationMessageRole role) { + return request.getInputs().get(0).getMessages().stream() + .filter(m -> m.getRole() == role) + .findFirst() + .orElseThrow(); + } + + private ConversationRequestAlpha2 capturedRequest() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(ConversationRequestAlpha2.class); + verify(client).converseAlpha2(captor.capture()); + return captor.getValue(); + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-registry/pom.xml b/quarkus/quarkus-langchain4j-dapr-registry/pom.xml new file mode 100644 index 0000000000..e70f0fa693 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-registry/pom.xml @@ -0,0 +1,92 @@ + + + 4.0.0 + + io.dapr.quarkus + dapr-quarkus-agentic-parent + 0.1.0-SNAPSHOT + ../pom.xml + + + quarkus-langchain4j-dapr-registry + Quarkus LangChain4j Dapr - Agent Registry + + + + io.quarkiverse.dapr + quarkus-dapr + ${quarkus-dapr.version} + + + com.fasterxml.jackson.core + jackson-databind + + + io.quarkiverse.langchain4j + quarkus-langchain4j-agentic + ${quarkus-langchain4j.version} + + + io.quarkus + quarkus-arc + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + 3.26.3 + test + + + org.mockito + mockito-core + 5.14.2 + test + + + io.quarkus + quarkus-junit + test + + + io.quarkiverse.langchain4j + quarkus-langchain4j-openai + ${quarkus-langchain4j.version} + test + + + org.awaitility + awaitility + test + + + + + + + maven-compiler-plugin + 3.13.0 + + + -parameters + + + + + maven-surefire-plugin + 3.5.2 + + + org.jboss.logmanager.LogManager + + + + + + diff --git a/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/model/AgentMetadata.java b/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/model/AgentMetadata.java new file mode 100644 index 0000000000..c3235156ec --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/model/AgentMetadata.java @@ -0,0 +1,172 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.agents.registry.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public class AgentMetadata { + + @JsonProperty("appid") + private String appId; + + @JsonProperty("type") + private String type; + + @JsonProperty("orchestrator") + private boolean orchestrator; + + @JsonProperty("role") + private String role = ""; + + @JsonProperty("goal") + private String goal = ""; + + @JsonProperty("instructions") + private List instructions; + + @JsonProperty("statestore") + private String statestore; + + @JsonProperty("system_prompt") + private String systemPrompt; + + @JsonProperty("framework") + private String framework; + + public AgentMetadata() { + } + + private AgentMetadata(Builder builder) { + this.appId = builder.appId; + this.type = builder.type; + this.orchestrator = builder.orchestrator; + this.role = builder.role; + this.goal = builder.goal; + this.instructions = builder.instructions; + this.statestore = builder.statestore; + this.systemPrompt = builder.systemPrompt; + this.framework = builder.framework; + } + + public static Builder builder() { + return new Builder(); + } + + public String getAppId() { + return appId; + } + + public String getType() { + return type; + } + + public boolean isOrchestrator() { + return orchestrator; + } + + public String getRole() { + return role; + } + + public String getGoal() { + return goal; + } + + public List getInstructions() { + return instructions; + } + + public String getStatestore() { + return statestore; + } + + public String getSystemPrompt() { + return systemPrompt; + } + + public String getFramework() { + return framework; + } + + public static class Builder { + private String appId; + private String type; + private boolean orchestrator = false; + private String role = ""; + private String goal = ""; + private List instructions; + private String statestore; + private String systemPrompt; + private String framework; + + public Builder appId(String appId) { + this.appId = appId; + return this; + } + + public Builder type(String type) { + this.type = type; + return this; + } + + public Builder orchestrator(boolean orchestrator) { + this.orchestrator = orchestrator; + return this; + } + + public Builder role(String role) { + this.role = role; + return this; + } + + public Builder goal(String goal) { + this.goal = goal; + return this; + } + + public Builder instructions(List instructions) { + this.instructions = instructions; + return this; + } + + public Builder statestore(String statestore) { + this.statestore = statestore; + return this; + } + + public Builder systemPrompt(String systemPrompt) { + this.systemPrompt = systemPrompt; + return this; + } + + public Builder framework(String framework) { + this.framework = framework; + return this; + } + + /** + * Builds the AgentMetadata, validating required fields. + * + * @return the constructed AgentMetadata + */ + public AgentMetadata build() { + if (appId == null || type == null) { + throw new IllegalStateException("appId and type are required"); + } + return new AgentMetadata(this); + } + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/model/AgentMetadataSchema.java b/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/model/AgentMetadataSchema.java new file mode 100644 index 0000000000..13be0a0da5 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/model/AgentMetadataSchema.java @@ -0,0 +1,232 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.agents.registry.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class AgentMetadataSchema { + + @JsonProperty("schema_version") + private String schemaVersion; + + @JsonProperty("agent") + private AgentMetadata agent; + + @JsonProperty("name") + private String name; + + @JsonProperty("registered_at") + private String registeredAt; + + @JsonProperty("pubsub") + private PubSubMetadata pubsub; + + @JsonProperty("memory") + private MemoryMetadata memory; + + @JsonProperty("llm") + private LlmMetadata llm; + + @JsonProperty("registry") + private RegistryMetadata registry; + + @JsonProperty("tools") + private List tools; + + @JsonProperty("max_iterations") + private Integer maxIterations; + + @JsonProperty("tool_choice") + private String toolChoice; + + @JsonProperty("agent_metadata") + private Map agentMetadata; + + public AgentMetadataSchema() { + } + + private AgentMetadataSchema(Builder builder) { + this.schemaVersion = builder.schemaVersion; + this.agent = builder.agent; + this.name = builder.name; + this.registeredAt = builder.registeredAt; + this.pubsub = builder.pubsub; + this.memory = builder.memory; + this.llm = builder.llm; + this.registry = builder.registry; + this.tools = builder.tools; + this.maxIterations = builder.maxIterations; + this.toolChoice = builder.toolChoice; + this.agentMetadata = builder.agentMetadata; + } + + public static Builder builder() { + return new Builder(); + } + + public String getSchemaVersion() { + return schemaVersion; + } + + public AgentMetadata getAgent() { + return agent; + } + + public String getName() { + return name; + } + + public String getRegisteredAt() { + return registeredAt; + } + + public PubSubMetadata getPubsub() { + return pubsub; + } + + public MemoryMetadata getMemory() { + return memory; + } + + public LlmMetadata getLlm() { + return llm; + } + + public RegistryMetadata getRegistry() { + return registry; + } + + public List getTools() { + return tools; + } + + public Integer getMaxIterations() { + return maxIterations; + } + + public String getToolChoice() { + return toolChoice; + } + + public Map getAgentMetadata() { + return agentMetadata; + } + + public static class Builder { + private String schemaVersion; + private AgentMetadata agent; + private String name; + private String registeredAt; + private PubSubMetadata pubsub; + private MemoryMetadata memory; + private LlmMetadata llm; + private RegistryMetadata registry; + private List tools; + private Integer maxIterations; + private String toolChoice; + private Map agentMetadata; + + public Builder schemaVersion(String schemaVersion) { + this.schemaVersion = schemaVersion; + return this; + } + + public Builder agent(AgentMetadata agent) { + this.agent = agent; + return this; + } + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder registeredAt(String registeredAt) { + this.registeredAt = registeredAt; + return this; + } + + public Builder pubsub(PubSubMetadata pubsub) { + this.pubsub = pubsub; + return this; + } + + public Builder memory(MemoryMetadata memory) { + this.memory = memory; + return this; + } + + public Builder llm(LlmMetadata llm) { + this.llm = llm; + return this; + } + + public Builder registry(RegistryMetadata registry) { + this.registry = registry; + return this; + } + + public Builder tools(List tools) { + this.tools = tools; + return this; + } + + /** + * Adds a single tool to the tools list, initializing the list if needed. + * + * @param tool the tool metadata to add + * @return this builder + */ + public Builder addTool(ToolMetadata tool) { + if (this.tools == null) { + this.tools = new ArrayList<>(); + } + this.tools.add(tool); + return this; + } + + public Builder maxIterations(Integer maxIterations) { + this.maxIterations = maxIterations; + return this; + } + + public Builder toolChoice(String toolChoice) { + this.toolChoice = toolChoice; + return this; + } + + public Builder agentMetadata(Map agentMetadata) { + this.agentMetadata = agentMetadata; + return this; + } + + /** + * Builds the AgentMetadataSchema, validating required fields. + * + * @return the constructed AgentMetadataSchema + */ + public AgentMetadataSchema build() { + if (schemaVersion == null || agent == null || name == null || registeredAt == null) { + throw new IllegalStateException("schemaVersion, agent, name, and registeredAt are required"); + } + return new AgentMetadataSchema(this); + } + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/model/LlmMetadata.java b/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/model/LlmMetadata.java new file mode 100644 index 0000000000..8381ec6061 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/model/LlmMetadata.java @@ -0,0 +1,170 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.agents.registry.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class LlmMetadata { + + @JsonProperty("client") + private String client; + + @JsonProperty("provider") + private String provider; + + @JsonProperty("api") + private String api = "unknown"; + + @JsonProperty("model") + private String model = "unknown"; + + @JsonProperty("component_name") + private String componentName; + + @JsonProperty("base_url") + private String baseUrl; + + @JsonProperty("azure_endpoint") + private String azureEndpoint; + + @JsonProperty("azure_deployment") + private String azureDeployment; + + @JsonProperty("prompt_template") + private String promptTemplate; + + public LlmMetadata() { + } + + private LlmMetadata(Builder builder) { + this.client = builder.client; + this.provider = builder.provider; + this.api = builder.api; + this.model = builder.model; + this.componentName = builder.componentName; + this.baseUrl = builder.baseUrl; + this.azureEndpoint = builder.azureEndpoint; + this.azureDeployment = builder.azureDeployment; + this.promptTemplate = builder.promptTemplate; + } + + public static Builder builder() { + return new Builder(); + } + + public String getClient() { + return client; + } + + public String getProvider() { + return provider; + } + + public String getApi() { + return api; + } + + public String getModel() { + return model; + } + + public String getComponentName() { + return componentName; + } + + public String getBaseUrl() { + return baseUrl; + } + + public String getAzureEndpoint() { + return azureEndpoint; + } + + public String getAzureDeployment() { + return azureDeployment; + } + + public String getPromptTemplate() { + return promptTemplate; + } + + public static class Builder { + private String client; + private String provider; + private String api = "unknown"; + private String model = "unknown"; + private String componentName; + private String baseUrl; + private String azureEndpoint; + private String azureDeployment; + private String promptTemplate; + + public Builder client(String client) { + this.client = client; + return this; + } + + public Builder provider(String provider) { + this.provider = provider; + return this; + } + + public Builder api(String api) { + this.api = api; + return this; + } + + public Builder model(String model) { + this.model = model; + return this; + } + + public Builder componentName(String componentName) { + this.componentName = componentName; + return this; + } + + public Builder baseUrl(String baseUrl) { + this.baseUrl = baseUrl; + return this; + } + + public Builder azureEndpoint(String azureEndpoint) { + this.azureEndpoint = azureEndpoint; + return this; + } + + public Builder azureDeployment(String azureDeployment) { + this.azureDeployment = azureDeployment; + return this; + } + + public Builder promptTemplate(String promptTemplate) { + this.promptTemplate = promptTemplate; + return this; + } + + /** + * Builds the LlmMetadata, validating required fields. + * + * @return the constructed LlmMetadata + */ + public LlmMetadata build() { + if (client == null || provider == null) { + throw new IllegalStateException("client and provider are required"); + } + return new LlmMetadata(this); + } + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/model/MemoryMetadata.java b/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/model/MemoryMetadata.java new file mode 100644 index 0000000000..a29a729095 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/model/MemoryMetadata.java @@ -0,0 +1,72 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.agents.registry.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class MemoryMetadata { + + @JsonProperty("type") + private String type; + + @JsonProperty("statestore") + private String statestore; + + public MemoryMetadata() { + } + + private MemoryMetadata(Builder builder) { + this.type = builder.type; + this.statestore = builder.statestore; + } + + public static Builder builder() { + return new Builder(); + } + + public String getType() { + return type; + } + + public String getStatestore() { + return statestore; + } + + public static class Builder { + private String type; + private String statestore; + + public Builder type(String type) { + this.type = type; + return this; + } + + public Builder statestore(String statestore) { + this.statestore = statestore; + return this; + } + + /** + * Builds a new MemoryMetadata instance. + * + * @return the built MemoryMetadata + */ + public MemoryMetadata build() { + if (type == null) { + throw new IllegalStateException("type is required"); + } + return new MemoryMetadata(this); + } + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/model/PubSubMetadata.java b/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/model/PubSubMetadata.java new file mode 100644 index 0000000000..3364c3840c --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/model/PubSubMetadata.java @@ -0,0 +1,86 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.agents.registry.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class PubSubMetadata { + + @JsonProperty("name") + private String name; + + @JsonProperty("broadcast_topic") + private String broadcastTopic; + + @JsonProperty("agent_topic") + private String agentTopic; + + public PubSubMetadata() { + } + + private PubSubMetadata(Builder builder) { + this.name = builder.name; + this.broadcastTopic = builder.broadcastTopic; + this.agentTopic = builder.agentTopic; + } + + public static Builder builder() { + return new Builder(); + } + + public String getName() { + return name; + } + + public String getBroadcastTopic() { + return broadcastTopic; + } + + public String getAgentTopic() { + return agentTopic; + } + + public static class Builder { + private String name; + private String broadcastTopic; + private String agentTopic; + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder broadcastTopic(String broadcastTopic) { + this.broadcastTopic = broadcastTopic; + return this; + } + + public Builder agentTopic(String agentTopic) { + this.agentTopic = agentTopic; + return this; + } + + /** + * Builds a new PubSubMetadata instance. + * + * @return the built PubSubMetadata + */ + public PubSubMetadata build() { + if (name == null) { + throw new IllegalStateException("name is required"); + } + return new PubSubMetadata(this); + } + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/model/RegistryMetadata.java b/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/model/RegistryMetadata.java new file mode 100644 index 0000000000..45380681b3 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/model/RegistryMetadata.java @@ -0,0 +1,64 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.agents.registry.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class RegistryMetadata { + + @JsonProperty("statestore") + private String statestore; + + @JsonProperty("name") + private String name; + + public RegistryMetadata() { + } + + private RegistryMetadata(Builder builder) { + this.statestore = builder.statestore; + this.name = builder.name; + } + + public static Builder builder() { + return new Builder(); + } + + public String getStatestore() { + return statestore; + } + + public String getName() { + return name; + } + + public static class Builder { + private String statestore; + private String name; + + public Builder statestore(String statestore) { + this.statestore = statestore; + return this; + } + + public Builder name(String name) { + this.name = name; + return this; + } + + public RegistryMetadata build() { + return new RegistryMetadata(this); + } + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/model/ToolMetadata.java b/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/model/ToolMetadata.java new file mode 100644 index 0000000000..d0cac5c3bc --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/model/ToolMetadata.java @@ -0,0 +1,86 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.agents.registry.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class ToolMetadata { + + @JsonProperty("tool_name") + private String toolName; + + @JsonProperty("tool_description") + private String toolDescription; + + @JsonProperty("tool_args") + private String toolArgs; + + public ToolMetadata() { + } + + private ToolMetadata(Builder builder) { + this.toolName = builder.toolName; + this.toolDescription = builder.toolDescription; + this.toolArgs = builder.toolArgs; + } + + public static Builder builder() { + return new Builder(); + } + + public String getToolName() { + return toolName; + } + + public String getToolDescription() { + return toolDescription; + } + + public String getToolArgs() { + return toolArgs; + } + + public static class Builder { + private String toolName; + private String toolDescription; + private String toolArgs; + + public Builder toolName(String toolName) { + this.toolName = toolName; + return this; + } + + public Builder toolDescription(String toolDescription) { + this.toolDescription = toolDescription; + return this; + } + + public Builder toolArgs(String toolArgs) { + this.toolArgs = toolArgs; + return this; + } + + /** + * Builds the ToolMetadata, validating required fields. + * + * @return the constructed ToolMetadata + */ + public ToolMetadata build() { + if (toolName == null || toolDescription == null || toolArgs == null) { + throw new IllegalStateException("toolName, toolDescription, and toolArgs are required"); + } + return new ToolMetadata(this); + } + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/service/AgentRegistry.java b/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/service/AgentRegistry.java new file mode 100644 index 0000000000..5f4105f828 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-registry/src/main/java/io/dapr/quarkus/agents/registry/service/AgentRegistry.java @@ -0,0 +1,410 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.agents.registry.service; + +import io.dapr.client.DaprClient; +import io.dapr.client.domain.GetStateRequest; +import io.dapr.client.domain.State; +import io.dapr.quarkus.agents.registry.model.AgentMetadata; +import io.dapr.quarkus.agents.registry.model.AgentMetadataSchema; +import io.dapr.utils.TypeRef; +import io.quarkus.runtime.StartupEvent; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.Any; +import jakarta.enterprise.inject.spi.Bean; +import jakarta.enterprise.inject.spi.BeanManager; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@ApplicationScoped +public class AgentRegistry { + + private static final Logger LOG = Logger.getLogger(AgentRegistry.class); + + /** + * Fully-qualified name of langchain4j {@code @Agent} annotation. + */ + private static final String AGENT_ANNOTATION_NAME = "dev.langchain4j.agentic.Agent"; + + /** + * Fully-qualified name of langchain4j {@code @SystemMessage} annotation. + */ + private static final String SYSTEM_MESSAGE_ANNOTATION_NAME = "dev.langchain4j.service.SystemMessage"; + + @Inject + DaprClient client; + + @Inject + BeanManager beanManager; + + @ConfigProperty(name = "dapr.agents.statestore", defaultValue = "kvstore") + String statestore; + + @ConfigProperty(name = "dapr.appid", defaultValue = "local-dapr-app") + String appId; + + @ConfigProperty(name = "dapr.agents.team", defaultValue = "default") + String team; + + void onStartup(@Observes StartupEvent event) { + discoverAndRegisterAgents(); + } + + void discoverAndRegisterAgents() { + LOG.info("Starting agent auto-discovery..."); + Set> beans = beanManager.getBeans(Object.class, Any.Literal.INSTANCE); + LOG.debugf("Found %d CDI beans to scan", beans.size()); + + Set scannedInterfaces = new HashSet<>(); + // Collect all interface classes from CDI beans first + List> interfacesToScan = new ArrayList<>(); + + for (Bean bean : beans) { + for (Type type : bean.getTypes()) { + if (type instanceof Class clazz && clazz.isInterface()) { + if (scannedInterfaces.add(clazz.getName())) { + interfacesToScan.add(clazz); + } + } + } + } + + // Also discover sub-agent classes referenced by composite agent annotations + // (e.g., @SequenceAgent(subAgents = {CreativeWriter.class, StyleEditor.class})). + // Sub-agents are often not CDI beans themselves, so they won't appear in BeanManager. + List> subAgentClasses = new ArrayList<>(); + for (Class iface : interfacesToScan) { + for (Method method : iface.getDeclaredMethods()) { + for (Annotation ann : method.getDeclaredAnnotations()) { + for (Class subAgent : extractSubAgentClasses(ann)) { + if (subAgent.isInterface() && scannedInterfaces.add(subAgent.getName())) { + subAgentClasses.add(subAgent); + LOG.debugf("Discovered sub-agent interface %s from %s on %s", + subAgent.getName(), ann.annotationType().getSimpleName(), iface.getName()); + } + } + } + } + } + interfacesToScan.addAll(subAgentClasses); + + // Scan all collected interfaces for @Agent and composite agent methods + int registered = 0; + int failed = 0; + for (Class iface : interfacesToScan) { + LOG.debugf("Scanning interface: %s", iface.getName()); + List agents = scanForAgents(iface, appId); + agents.addAll(scanForCompositeAgents(iface, appId)); + if (!agents.isEmpty()) { + LOG.debugf("Found %d agent(s) on interface %s", agents.size(), iface.getName()); + } + for (AgentMetadataSchema schema : agents) { + try { + registerAgent(schema); + registered++; + } catch (Exception e) { + failed++; + LOG.errorf(e, "Failed to register agent '%s' in state store '%s': %s", + schema.getName(), statestore, e.getMessage()); + } + } + } + + LOG.debugf("Scanned %d unique interfaces", scannedInterfaces.size()); + + if (registered == 0 && failed == 0) { + LOG.warn("No @Agent-annotated methods found on any CDI bean interface. " + + "Ensure your @Agent interfaces are registered as CDI beans."); + } else if (failed > 0) { + LOG.warnf("Agent discovery complete: %d registered, %d failed", registered, failed); + } else { + LOG.infof("Agent discovery complete: %d agent(s) registered successfully", registered); + } + } + + /** + * Extracts sub-agent classes from a composite agent annotation. + * + *

Looks for a {@code subAgents()} method returning {@code Class[]} on the annotation. + * This works for any composite agent annotation (e.g., {@code @SequenceAgent}, + * {@code @ParallelAgent}, etc.) without coupling to specific annotation types. + */ + static Class[] extractSubAgentClasses(Annotation ann) { + try { + Method subAgentsMethod = ann.annotationType().getMethod("subAgents"); + Object result = subAgentsMethod.invoke(ann); + if (result instanceof Class[] classes) { + return classes; + } + } catch (NoSuchMethodException e) { + // Not a composite agent annotation — expected for most annotations + } catch (Exception e) { + LOG.debugf("Failed to extract subAgents from %s: %s", + ann.annotationType().getSimpleName(), e.getMessage()); + } + return new Class[0]; + } + + /** + * Scans an interface for methods annotated with {@code @Agent} and extracts metadata. + * + *

Uses name-based annotation matching ({@code annotationType().getName()}) instead of + * class identity ({@code method.getAnnotation(Agent.class)}) to handle classloader + * differences between library JARs and the Quarkus application classloader. + */ + static List scanForAgents(Class type, String appId) { + List result = new ArrayList<>(); + for (Method method : type.getDeclaredMethods()) { + Annotation agentAnn = findAnnotationByName(method, AGENT_ANNOTATION_NAME); + if (agentAnn == null) { + continue; + } + + String name = invokeStringMethod(agentAnn, "name"); + if (name == null || name.isBlank()) { + name = type.getSimpleName() + "." + method.getName(); + } + + String goal = invokeStringMethod(agentAnn, "description"); + + String systemPrompt = null; + Annotation smAnn = findAnnotationByName(method, SYSTEM_MESSAGE_ANNOTATION_NAME); + if (smAnn != null) { + String[] values = invokeMethod(smAnn, "value", String[].class); + String delimiter = invokeStringMethod(smAnn, "delimiter"); + if (values != null && values.length > 0) { + String joined = String.join(delimiter != null ? delimiter : "\n", values); + if (!joined.isBlank()) { + systemPrompt = joined; + } + } + } + + String agentType = resolveAgentType(type, method); + + AgentMetadataSchema schema = AgentMetadataSchema.builder() + .schemaVersion("0.11.1") + .name(name) + .registeredAt(Instant.now().toString()) + .agent(AgentMetadata.builder() + .appId(appId) + .type(agentType) + .goal(goal) + .systemPrompt(systemPrompt) + .framework("langchain4j") + .build()) + .build(); + + result.add(schema); + } + return result; + } + + /** + * Resolves the agent type based on annotations on the method and declaring interface. + * Returns "Agent", "SequenceAgent", "ParallelAgent", "LoopAgent", or "ConditionalAgent". + * + * @param type the declaring interface + * @param method the annotated method + * @return the agent type string + */ + private static final String[] COMPOSITE_ANNOTATIONS = { + "dev.langchain4j.agentic.declarative.SequenceAgent", + "dev.langchain4j.agentic.declarative.ParallelAgent", + "dev.langchain4j.agentic.declarative.LoopAgent", + "dev.langchain4j.agentic.declarative.ConditionalAgent" + }; + + /** + * Scans an interface for composite agent annotations (@SequenceAgent, @ParallelAgent, etc.) + * and creates metadata entries for them. + * + * @param type the interface to scan + * @param appId the application ID + * @return list of agent metadata schemas for composite agents + */ + static List scanForCompositeAgents(Class type, String appId) { + List result = new ArrayList<>(); + for (Method method : type.getDeclaredMethods()) { + for (Annotation ann : method.getDeclaredAnnotations()) { + String annFqcn = ann.annotationType().getName(); + final String annSimple = ann.annotationType().getSimpleName(); + boolean isComposite = false; + for (String expected : COMPOSITE_ANNOTATIONS) { + if (expected.equals(annFqcn)) { + isComposite = true; + break; + } + } + if (!isComposite) { + continue; + } + + String name = invokeStringMethod(ann, "name"); + if (name == null || name.isBlank()) { + name = type.getSimpleName() + "." + method.getName(); + } + String description = invokeStringMethod(ann, "description"); + + AgentMetadataSchema schema = AgentMetadataSchema.builder() + .schemaVersion("0.11.1") + .name(name) + .registeredAt(Instant.now().toString()) + .agent(AgentMetadata.builder() + .appId(appId) + .type(annSimple) + .goal(description) + .framework("langchain4j") + .build()) + .build(); + result.add(schema); + } + } + return result; + } + + /** + * Resolves the agent type by checking the declaring interface for composite + * agent annotations. Falls back to "Agent" for standalone agents. + * + * @param type the declaring interface + * @param method the annotated method + * @return the agent type string + */ + private static String resolveAgentType(Class type, Method method) { + // Check all methods on the declaring interface for composite annotations + for (Method m : type.getDeclaredMethods()) { + for (Annotation ann : m.getDeclaredAnnotations()) { + String annName = ann.annotationType().getSimpleName(); + switch (annName) { + case "SequenceAgent": + return "SequenceAgent"; + case "ParallelAgent": + return "ParallelAgent"; + case "LoopAgent": + return "LoopAgent"; + case "ConditionalAgent": + return "ConditionalAgent"; + default: + break; + } + } + } + return "Agent"; + } + + /** + * Finds an annotation on a method by its fully-qualified type name. + * This is resilient against classloader mismatches where the same annotation class + * may be loaded by different classloaders. + */ + private static Annotation findAnnotationByName(Method method, String annotationName) { + for (Annotation ann : method.getDeclaredAnnotations()) { + if (ann.annotationType().getName().equals(annotationName)) { + return ann; + } + } + return null; + } + + /** + * Invokes a no-arg method on an annotation proxy and returns the result as a String. + */ + private static String invokeStringMethod(Annotation ann, String methodName) { + return invokeMethod(ann, methodName, String.class); + } + + /** + * Invokes a no-arg method on an annotation proxy, casting to the expected type. + */ + @SuppressWarnings("unchecked") + private static T invokeMethod(Annotation ann, String methodName, Class returnType) { + try { + Object result = ann.annotationType().getMethod(methodName).invoke(ann); + return returnType.isInstance(result) ? (T) result : null; + } catch (Exception e) { + LOG.debugf("Failed to invoke %s.%s(): %s", + ann.annotationType().getSimpleName(), methodName, e.getMessage()); + return null; + } + } + + /** + * Registers an agent schema in the Dapr state store and updates the team index. + * + *

Two-step operation matching the Python dapr-agents registration protocol: + *

    + *
  1. Save per-agent metadata key ({@code agents:{team}:{name}})
  2. + *
  3. Update the team index key ({@code agents:{team}:_index}) adding this agent name
  4. + *
+ * + *

The team index is how dapr-agents discovers peers in multi-agent workflows. + * + * @param schema the agent metadata schema to register + */ + @SuppressWarnings("unchecked") + public void registerAgent(AgentMetadataSchema schema) { + String registryPrefix = "agents:" + team; + Map meta = Map.of( + "contentType", "application/json", + "partitionKey", registryPrefix); + + // Step 1: Save per-agent metadata key + String agentKey = registryPrefix + ":" + schema.getName(); + LOG.infof("Registering agent: %s", agentKey); + client.saveState(statestore, agentKey, null, schema, meta, null).block(); + + // Step 2: Update team index (adds agent name to the list) + String indexKey = registryPrefix + ":_index"; + GetStateRequest getRequest = new GetStateRequest(statestore, indexKey) + .setMetadata(meta); + State indexState = client.getState(getRequest, TypeRef.get(Map.class)).block(); + + Map indexData; + if (indexState != null && indexState.getValue() != null) { + indexData = new HashMap<>((Map) indexState.getValue()); + } else { + indexData = new HashMap<>(); + } + + List agentsList; + Object existing = indexData.get("agents"); + if (existing instanceof List) { + agentsList = new ArrayList<>((List) existing); + } else { + agentsList = new ArrayList<>(); + } + + if (!agentsList.contains(schema.getName())) { + agentsList.add(schema.getName()); + indexData.put("agents", agentsList); + String etag = (indexState != null) ? indexState.getEtag() : null; + client.saveState(statestore, indexKey, etag, indexData, meta, null).block(); + LOG.infof("Updated team index '%s' with agent '%s'", indexKey, schema.getName()); + } + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-registry/src/main/resources/META-INF/beans.xml b/quarkus/quarkus-langchain4j-dapr-registry/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000000..162171f0fb --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-registry/src/main/resources/META-INF/beans.xml @@ -0,0 +1,6 @@ + + + diff --git a/quarkus/quarkus-langchain4j-dapr-registry/src/main/resources/schema.json b/quarkus/quarkus-langchain4j-dapr-registry/src/main/resources/schema.json new file mode 100644 index 0000000000..5e926ac93a --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-registry/src/main/resources/schema.json @@ -0,0 +1,461 @@ +{ + "$defs": { + "AgentMetadata": { + "description": "Metadata about an agent's configuration and capabilities.", + "properties": { + "appid": { + "description": "Dapr application ID of the agent", + "title": "Appid", + "type": "string" + }, + "type": { + "description": "Type of the agent (e.g., standalone, durable)", + "title": "Type", + "type": "string" + }, + "orchestrator": { + "default": false, + "description": "Indicates if the agent is an orchestrator", + "title": "Orchestrator", + "type": "boolean" + }, + "role": { + "default": "", + "description": "Role of the agent", + "title": "Role", + "type": "string" + }, + "goal": { + "default": "", + "description": "High-level objective of the agent", + "title": "Goal", + "type": "string" + }, + "instructions": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Instructions for the agent", + "title": "Instructions" + }, + "statestore": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dapr state store component name used by the agent", + "title": "Statestore" + }, + "system_prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "System prompt guiding the agent's behavior", + "title": "System Prompt" + }, + "framework": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Framework or library the agent is built with", + "title": "Framework" + } + }, + "required": [ + "appid", + "type" + ], + "title": "AgentMetadata", + "type": "object" + }, + "LLMMetadata": { + "description": "LLM configuration information.", + "properties": { + "client": { + "description": "LLM client used by the agent", + "title": "Client", + "type": "string" + }, + "provider": { + "description": "LLM provider used by the agent", + "title": "Provider", + "type": "string" + }, + "api": { + "default": "unknown", + "description": "API type used by the LLM client", + "title": "Api", + "type": "string" + }, + "model": { + "default": "unknown", + "description": "Model name or identifier", + "title": "Model", + "type": "string" + }, + "component_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dapr component name for the LLM client", + "title": "Component Name" + }, + "base_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Base URL for the LLM API if applicable", + "title": "Base Url" + }, + "azure_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Azure endpoint if using Azure OpenAI", + "title": "Azure Endpoint" + }, + "azure_deployment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Azure deployment name if using Azure OpenAI", + "title": "Azure Deployment" + }, + "prompt_template": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Prompt template used by the agent", + "title": "Prompt Template" + } + }, + "required": [ + "client", + "provider" + ], + "title": "LLMMetadata", + "type": "object" + }, + "MemoryMetadata": { + "description": "Memory configuration information.", + "properties": { + "type": { + "description": "Type of memory used by the agent", + "title": "Type", + "type": "string" + }, + "statestore": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dapr state store component name for memory", + "title": "Statestore" + } + }, + "required": [ + "type" + ], + "title": "MemoryMetadata", + "type": "object" + }, + "PubSubMetadata": { + "description": "Pub/Sub configuration information.", + "properties": { + "name": { + "description": "Pub/Sub component name", + "title": "Name", + "type": "string" + }, + "broadcast_topic": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Pub/Sub topic for broadcasting messages", + "title": "Broadcast Topic" + }, + "agent_topic": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Pub/Sub topic for direct agent messages", + "title": "Agent Topic" + } + }, + "required": [ + "name" + ], + "title": "PubSubMetadata", + "type": "object" + }, + "RegistryMetadata": { + "description": "Registry configuration information.", + "properties": { + "statestore": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of the statestore component for the registry", + "title": "Statestore" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of the team registry", + "title": "Name" + } + }, + "title": "RegistryMetadata", + "type": "object" + }, + "ToolMetadata": { + "description": "Metadata about a tool available to the agent.", + "properties": { + "tool_name": { + "description": "Name of the tool", + "title": "Tool Name", + "type": "string" + }, + "tool_description": { + "description": "Description of the tool's functionality", + "title": "Tool Description", + "type": "string" + }, + "tool_args": { + "description": "Arguments for the tool", + "title": "Tool Args", + "type": "string" + } + }, + "required": [ + "tool_name", + "tool_description", + "tool_args" + ], + "title": "ToolMetadata", + "type": "object" + } + }, + "description": "Schema for agent metadata including schema version.", + "properties": { + "schema_version": { + "description": "Version of the schema used for the agent metadata.", + "title": "Schema Version", + "type": "string" + }, + "agent": { + "$ref": "#/$defs/AgentMetadata", + "description": "Agent configuration and capabilities" + }, + "name": { + "description": "Name of the agent", + "title": "Name", + "type": "string" + }, + "registered_at": { + "description": "ISO 8601 timestamp of registration", + "title": "Registered At", + "type": "string" + }, + "pubsub": { + "anyOf": [ + { + "$ref": "#/$defs/PubSubMetadata" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Pub/sub configuration if enabled" + }, + "memory": { + "anyOf": [ + { + "$ref": "#/$defs/MemoryMetadata" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Memory configuration if enabled" + }, + "llm": { + "anyOf": [ + { + "$ref": "#/$defs/LLMMetadata" + }, + { + "type": "null" + } + ], + "default": null, + "description": "LLM configuration" + }, + "registry": { + "anyOf": [ + { + "$ref": "#/$defs/RegistryMetadata" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Registry configuration" + }, + "tools": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/ToolMetadata" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Available tools", + "title": "Tools" + }, + "max_iterations": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Maximum iterations for agent execution", + "title": "Max Iterations" + }, + "tool_choice": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Tool choice strategy", + "title": "Tool Choice" + }, + "agent_metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Additional metadata about the agent", + "title": "Agent Metadata" + } + }, + "required": [ + "schema_version", + "agent", + "name", + "registered_at" + ], + "title": "AgentMetadataSchema", + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "version": "0.11.1" +} \ No newline at end of file diff --git a/quarkus/quarkus-langchain4j-dapr-registry/src/test/java/io/dapr/quarkus/agents/registry/model/AgentMetadataSchemaTest.java b/quarkus/quarkus-langchain4j-dapr-registry/src/test/java/io/dapr/quarkus/agents/registry/model/AgentMetadataSchemaTest.java new file mode 100644 index 0000000000..095e0bd836 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-registry/src/test/java/io/dapr/quarkus/agents/registry/model/AgentMetadataSchemaTest.java @@ -0,0 +1,404 @@ +package io.dapr.quarkus.agents.registry.model; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AgentMetadataSchemaTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void buildMinimalSchema() { + AgentMetadataSchema schema = AgentMetadataSchema.builder() + .schemaVersion("0.11.1") + .name("test-agent") + .registeredAt("2025-01-01T00:00:00Z") + .agent(AgentMetadata.builder() + .appId("my-app") + .type("standalone") + .build()) + .build(); + + assertThat(schema.getSchemaVersion()).isEqualTo("0.11.1"); + assertThat(schema.getName()).isEqualTo("test-agent"); + assertThat(schema.getRegisteredAt()).isEqualTo("2025-01-01T00:00:00Z"); + assertThat(schema.getAgent().getAppId()).isEqualTo("my-app"); + assertThat(schema.getAgent().getType()).isEqualTo("standalone"); + assertThat(schema.getAgent().isOrchestrator()).isFalse(); + assertThat(schema.getAgent().getRole()).isEmpty(); + assertThat(schema.getAgent().getGoal()).isEmpty(); + assertThat(schema.getPubsub()).isNull(); + assertThat(schema.getMemory()).isNull(); + assertThat(schema.getLlm()).isNull(); + assertThat(schema.getRegistry()).isNull(); + assertThat(schema.getTools()).isNull(); + assertThat(schema.getMaxIterations()).isNull(); + assertThat(schema.getToolChoice()).isNull(); + assertThat(schema.getAgentMetadata()).isNull(); + } + + @Test + void buildFullSchema() { + AgentMetadataSchema schema = AgentMetadataSchema.builder() + .schemaVersion("0.11.1") + .name("orchestrator-agent") + .registeredAt("2025-06-15T10:30:00Z") + .agent(AgentMetadata.builder() + .appId("orch-app") + .type("durable") + .orchestrator(true) + .role("coordinator") + .goal("Coordinate tasks across agents") + .instructions(List.of("Be concise", "Delegate work")) + .statestore("statestore") + .systemPrompt("You are a coordinator agent.") + .framework("langchain4j") + .build()) + .pubsub(PubSubMetadata.builder() + .name("pubsub") + .broadcastTopic("broadcast") + .agentTopic("agent-messages") + .build()) + .memory(MemoryMetadata.builder() + .type("conversation") + .statestore("memory-store") + .build()) + .llm(LlmMetadata.builder() + .client("openai") + .provider("openai") + .api("chat") + .model("gpt-4") + .baseUrl("https://api.openai.com") + .promptTemplate("Answer: {input}") + .build()) + .registry(RegistryMetadata.builder() + .statestore("registry-store") + .name("team-registry") + .build()) + .addTool(ToolMetadata.builder() + .toolName("search") + .toolDescription("Search the web") + .toolArgs("{\"query\": \"string\"}") + .build()) + .addTool(ToolMetadata.builder() + .toolName("calculator") + .toolDescription("Perform calculations") + .toolArgs("{\"expression\": \"string\"}") + .build()) + .maxIterations(10) + .toolChoice("auto") + .agentMetadata(Map.of("version", "1.0", "team", "alpha")) + .build(); + + assertThat(schema.getAgent().isOrchestrator()).isTrue(); + assertThat(schema.getAgent().getRole()).isEqualTo("coordinator"); + assertThat(schema.getAgent().getGoal()).isEqualTo("Coordinate tasks across agents"); + assertThat(schema.getAgent().getInstructions()).containsExactly("Be concise", "Delegate work"); + assertThat(schema.getAgent().getStatestore()).isEqualTo("statestore"); + assertThat(schema.getAgent().getSystemPrompt()).isEqualTo("You are a coordinator agent."); + assertThat(schema.getAgent().getFramework()).isEqualTo("langchain4j"); + assertThat(schema.getPubsub().getName()).isEqualTo("pubsub"); + assertThat(schema.getPubsub().getBroadcastTopic()).isEqualTo("broadcast"); + assertThat(schema.getPubsub().getAgentTopic()).isEqualTo("agent-messages"); + assertThat(schema.getMemory().getType()).isEqualTo("conversation"); + assertThat(schema.getMemory().getStatestore()).isEqualTo("memory-store"); + assertThat(schema.getLlm().getClient()).isEqualTo("openai"); + assertThat(schema.getLlm().getProvider()).isEqualTo("openai"); + assertThat(schema.getLlm().getApi()).isEqualTo("chat"); + assertThat(schema.getLlm().getModel()).isEqualTo("gpt-4"); + assertThat(schema.getLlm().getBaseUrl()).isEqualTo("https://api.openai.com"); + assertThat(schema.getLlm().getPromptTemplate()).isEqualTo("Answer: {input}"); + assertThat(schema.getRegistry().getStatestore()).isEqualTo("registry-store"); + assertThat(schema.getRegistry().getName()).isEqualTo("team-registry"); + assertThat(schema.getTools()).hasSize(2); + assertThat(schema.getTools().get(0).getToolName()).isEqualTo("search"); + assertThat(schema.getTools().get(1).getToolName()).isEqualTo("calculator"); + assertThat(schema.getMaxIterations()).isEqualTo(10); + assertThat(schema.getToolChoice()).isEqualTo("auto"); + assertThat(schema.getAgentMetadata()).containsEntry("version", "1.0"); + } + + @Test + void buildLlmWithAzureConfig() { + LlmMetadata llm = LlmMetadata.builder() + .client("azure-openai") + .provider("azure") + .azureEndpoint("https://my-resource.openai.azure.com") + .azureDeployment("gpt-4-deployment") + .componentName("llm-component") + .build(); + + assertThat(llm.getAzureEndpoint()).isEqualTo("https://my-resource.openai.azure.com"); + assertThat(llm.getAzureDeployment()).isEqualTo("gpt-4-deployment"); + assertThat(llm.getComponentName()).isEqualTo("llm-component"); + assertThat(llm.getApi()).isEqualTo("unknown"); + assertThat(llm.getModel()).isEqualTo("unknown"); + } + + @Test + void buildLlmWithDefaults() { + LlmMetadata llm = LlmMetadata.builder() + .client("openai") + .provider("openai") + .build(); + + assertThat(llm.getApi()).isEqualTo("unknown"); + assertThat(llm.getModel()).isEqualTo("unknown"); + assertThat(llm.getComponentName()).isNull(); + assertThat(llm.getBaseUrl()).isNull(); + } + + @Test + void schemaBuilderRequiresAllRequiredFields() { + assertThatThrownBy(() -> AgentMetadataSchema.builder().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("schemaVersion, agent, name, and registeredAt are required"); + } + + @Test + void agentBuilderRequiresAppIdAndType() { + assertThatThrownBy(() -> AgentMetadata.builder().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("appId and type are required"); + } + + @Test + void llmBuilderRequiresClientAndProvider() { + assertThatThrownBy(() -> LlmMetadata.builder().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("client and provider are required"); + } + + @Test + void memoryBuilderRequiresType() { + assertThatThrownBy(() -> MemoryMetadata.builder().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("type is required"); + } + + @Test + void pubsubBuilderRequiresName() { + assertThatThrownBy(() -> PubSubMetadata.builder().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("name is required"); + } + + @Test + void toolBuilderRequiresAllFields() { + assertThatThrownBy(() -> ToolMetadata.builder().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("toolName, toolDescription, and toolArgs are required"); + } + + @Test + void registryBuilderHasNoRequiredFields() { + RegistryMetadata registry = RegistryMetadata.builder().build(); + assertThat(registry.getStatestore()).isNull(); + assertThat(registry.getName()).isNull(); + } + + @Test + void serializeMinimalSchemaToJson() throws Exception { + AgentMetadataSchema schema = AgentMetadataSchema.builder() + .schemaVersion("0.11.1") + .name("test-agent") + .registeredAt("2025-01-01T00:00:00Z") + .agent(AgentMetadata.builder() + .appId("my-app") + .type("standalone") + .build()) + .build(); + + String json = mapper.writeValueAsString(schema); + + assertThat(json).contains("\"schema_version\":\"0.11.1\""); + assertThat(json).contains("\"name\":\"test-agent\""); + assertThat(json).contains("\"registered_at\":\"2025-01-01T00:00:00Z\""); + assertThat(json).contains("\"appid\":\"my-app\""); + assertThat(json).contains("\"type\":\"standalone\""); + // null fields should be excluded + assertThat(json).doesNotContain("\"pubsub\""); + assertThat(json).doesNotContain("\"memory\""); + assertThat(json).doesNotContain("\"llm\""); + assertThat(json).doesNotContain("\"registry\""); + assertThat(json).doesNotContain("\"tools\""); + assertThat(json).doesNotContain("\"max_iterations\""); + assertThat(json).doesNotContain("\"tool_choice\""); + assertThat(json).doesNotContain("\"agent_metadata\""); + } + + @Test + void serializeAndDeserializeFullSchema() throws Exception { + AgentMetadataSchema original = AgentMetadataSchema.builder() + .schemaVersion("0.11.1") + .name("round-trip-agent") + .registeredAt("2025-06-15T10:30:00Z") + .agent(AgentMetadata.builder() + .appId("rt-app") + .type("durable") + .orchestrator(true) + .role("planner") + .goal("Plan tasks") + .instructions(List.of("Step 1", "Step 2")) + .statestore("state-store") + .systemPrompt("You are a planner.") + .framework("quarkus") + .build()) + .pubsub(PubSubMetadata.builder() + .name("pubsub") + .broadcastTopic("broadcast") + .agentTopic("agent-topic") + .build()) + .memory(MemoryMetadata.builder() + .type("buffer") + .statestore("mem-store") + .build()) + .llm(LlmMetadata.builder() + .client("openai") + .provider("openai") + .api("chat") + .model("gpt-4o") + .baseUrl("https://api.openai.com") + .build()) + .registry(RegistryMetadata.builder() + .statestore("reg-store") + .name("my-registry") + .build()) + .addTool(ToolMetadata.builder() + .toolName("search") + .toolDescription("Web search") + .toolArgs("{}") + .build()) + .maxIterations(5) + .toolChoice("auto") + .agentMetadata(Map.of("env", "prod")) + .build(); + + String json = mapper.writeValueAsString(original); + AgentMetadataSchema deserialized = mapper.readValue(json, AgentMetadataSchema.class); + + assertThat(deserialized.getSchemaVersion()).isEqualTo("0.11.1"); + assertThat(deserialized.getName()).isEqualTo("round-trip-agent"); + assertThat(deserialized.getRegisteredAt()).isEqualTo("2025-06-15T10:30:00Z"); + assertThat(deserialized.getAgent().getAppId()).isEqualTo("rt-app"); + assertThat(deserialized.getAgent().getType()).isEqualTo("durable"); + assertThat(deserialized.getAgent().isOrchestrator()).isTrue(); + assertThat(deserialized.getAgent().getRole()).isEqualTo("planner"); + assertThat(deserialized.getAgent().getGoal()).isEqualTo("Plan tasks"); + assertThat(deserialized.getAgent().getInstructions()).containsExactly("Step 1", "Step 2"); + assertThat(deserialized.getAgent().getStatestore()).isEqualTo("state-store"); + assertThat(deserialized.getAgent().getSystemPrompt()).isEqualTo("You are a planner."); + assertThat(deserialized.getAgent().getFramework()).isEqualTo("quarkus"); + assertThat(deserialized.getPubsub().getName()).isEqualTo("pubsub"); + assertThat(deserialized.getPubsub().getBroadcastTopic()).isEqualTo("broadcast"); + assertThat(deserialized.getPubsub().getAgentTopic()).isEqualTo("agent-topic"); + assertThat(deserialized.getMemory().getType()).isEqualTo("buffer"); + assertThat(deserialized.getMemory().getStatestore()).isEqualTo("mem-store"); + assertThat(deserialized.getLlm().getClient()).isEqualTo("openai"); + assertThat(deserialized.getLlm().getModel()).isEqualTo("gpt-4o"); + assertThat(deserialized.getLlm().getBaseUrl()).isEqualTo("https://api.openai.com"); + assertThat(deserialized.getRegistry().getStatestore()).isEqualTo("reg-store"); + assertThat(deserialized.getRegistry().getName()).isEqualTo("my-registry"); + assertThat(deserialized.getTools()).hasSize(1); + assertThat(deserialized.getTools().get(0).getToolName()).isEqualTo("search"); + assertThat(deserialized.getMaxIterations()).isEqualTo(5); + assertThat(deserialized.getToolChoice()).isEqualTo("auto"); + assertThat(deserialized.getAgentMetadata()).containsEntry("env", "prod"); + } + + @Test + void deserializeFromJson() throws Exception { + String json = """ + { + "schema_version": "0.11.1", + "name": "json-agent", + "registered_at": "2025-03-01T00:00:00Z", + "agent": { + "appid": "json-app", + "type": "standalone", + "orchestrator": false, + "role": "worker", + "goal": "Process tasks", + "instructions": ["Follow orders"], + "system_prompt": "You are a worker.", + "framework": "dapr" + }, + "llm": { + "client": "anthropic", + "provider": "anthropic", + "model": "claude-3" + }, + "tools": [ + { + "tool_name": "fetch", + "tool_description": "Fetch URL", + "tool_args": "{\\"url\\": \\"string\\"}" + } + ], + "max_iterations": 20, + "tool_choice": "required" + } + """; + + AgentMetadataSchema schema = mapper.readValue(json, AgentMetadataSchema.class); + + assertThat(schema.getSchemaVersion()).isEqualTo("0.11.1"); + assertThat(schema.getName()).isEqualTo("json-agent"); + assertThat(schema.getAgent().getAppId()).isEqualTo("json-app"); + assertThat(schema.getAgent().getType()).isEqualTo("standalone"); + assertThat(schema.getAgent().isOrchestrator()).isFalse(); + assertThat(schema.getAgent().getRole()).isEqualTo("worker"); + assertThat(schema.getAgent().getGoal()).isEqualTo("Process tasks"); + assertThat(schema.getAgent().getInstructions()).containsExactly("Follow orders"); + assertThat(schema.getAgent().getSystemPrompt()).isEqualTo("You are a worker."); + assertThat(schema.getAgent().getFramework()).isEqualTo("dapr"); + assertThat(schema.getLlm().getClient()).isEqualTo("anthropic"); + assertThat(schema.getLlm().getProvider()).isEqualTo("anthropic"); + assertThat(schema.getLlm().getModel()).isEqualTo("claude-3"); + assertThat(schema.getTools()).hasSize(1); + assertThat(schema.getTools().get(0).getToolName()).isEqualTo("fetch"); + assertThat(schema.getMaxIterations()).isEqualTo(20); + assertThat(schema.getToolChoice()).isEqualTo("required"); + assertThat(schema.getPubsub()).isNull(); + assertThat(schema.getMemory()).isNull(); + assertThat(schema.getRegistry()).isNull(); + } + + @Test + void addToolIncrementally() { + AgentMetadataSchema schema = AgentMetadataSchema.builder() + .schemaVersion("0.11.1") + .name("tool-agent") + .registeredAt("2025-01-01T00:00:00Z") + .agent(AgentMetadata.builder() + .appId("tool-app") + .type("standalone") + .build()) + .addTool(ToolMetadata.builder() + .toolName("t1") + .toolDescription("First tool") + .toolArgs("{}") + .build()) + .addTool(ToolMetadata.builder() + .toolName("t2") + .toolDescription("Second tool") + .toolArgs("{}") + .build()) + .addTool(ToolMetadata.builder() + .toolName("t3") + .toolDescription("Third tool") + .toolArgs("{}") + .build()) + .build(); + + assertThat(schema.getTools()).hasSize(3); + assertThat(schema.getTools()).extracting(ToolMetadata::getToolName) + .containsExactly("t1", "t2", "t3"); + } +} \ No newline at end of file diff --git a/quarkus/quarkus-langchain4j-dapr-registry/src/test/java/io/dapr/quarkus/agents/registry/service/AgentRegistryDevServicesTest.java b/quarkus/quarkus-langchain4j-dapr-registry/src/test/java/io/dapr/quarkus/agents/registry/service/AgentRegistryDevServicesTest.java new file mode 100644 index 0000000000..88b6afe4b0 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-registry/src/test/java/io/dapr/quarkus/agents/registry/service/AgentRegistryDevServicesTest.java @@ -0,0 +1,144 @@ +package io.dapr.quarkus.agents.registry.service; + +import io.dapr.client.DaprClient; +import io.dapr.client.domain.State; +import io.dapr.quarkus.agents.registry.model.AgentMetadataSchema; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import io.dapr.utils.TypeRef; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Integration tests for {@link AgentRegistry} using Dapr dev services. + *

+ * Requires Docker for Dapr dev services (starts daprd, placement, scheduler, + * PostgreSQL state store, and dashboard containers via Testcontainers). + * Uses {@link MockChatModel} instead of a real LLM. + * Uses {@link TestAgentBean} to provide a CDI bean with {@code @Agent}-annotated + * interface methods for the registry to discover. + */ +@QuarkusTest +class AgentRegistryDevServicesTest { + + private static final String STATE_STORE = "kvstore"; + private static final String TEAM = "test-team"; + private static final String APP_ID = "local-dapr-app"; + + @Inject + AgentRegistry registry; + + @Inject + DaprClient daprClient; + + @Test + void registryShouldBeInjectable() { + assertThat(registry).isNotNull(); + } + + @Test + void daprClientShouldBeInjectable() { + assertThat(daprClient).isNotNull(); + } + + @Test + void autoDiscoveredAgentWithPromptShouldBeInStateStore() { + await().atMost(Duration.ofSeconds(30)).untilAsserted(() -> { + State state = daprClient.getState( + STATE_STORE, "agents:" + TEAM + ":test-agent-with-prompt", AgentMetadataSchema.class).block(); + assertThat(state).isNotNull(); + assertThat(state.getValue()).isNotNull(); + + AgentMetadataSchema schema = state.getValue(); + assertThat(schema.getSchemaVersion()).isEqualTo("0.11.1"); + assertThat(schema.getName()).isEqualTo("test-agent-with-prompt"); + assertThat(schema.getAgent().getAppId()).isEqualTo(APP_ID); + assertThat(schema.getAgent().getType()).isEqualTo("Agent"); + assertThat(schema.getAgent().getGoal()).isEqualTo("Agent with system prompt"); + assertThat(schema.getAgent().getFramework()).isEqualTo("langchain4j"); + assertThat(schema.getAgent().getSystemPrompt()).isEqualTo("You are a test agent for integration testing."); + assertThat(schema.getRegisteredAt()).isNotBlank(); + }); + } + + @Test + void autoDiscoveredSimpleAgentShouldBeInStateStore() { + await().atMost(Duration.ofSeconds(30)).untilAsserted(() -> { + State state = daprClient.getState( + STATE_STORE, "agents:" + TEAM + ":test-agent-simple", AgentMetadataSchema.class).block(); + assertThat(state).isNotNull(); + assertThat(state.getValue()).isNotNull(); + + AgentMetadataSchema schema = state.getValue(); + assertThat(schema.getName()).isEqualTo("test-agent-simple"); + assertThat(schema.getAgent().getGoal()).isEqualTo("Simple agent without prompt"); + assertThat(schema.getAgent().getSystemPrompt()).isNull(); + assertThat(schema.getAgent().getFramework()).isEqualTo("langchain4j"); + }); + } + + @Test + void agentWithDefaultNameShouldUseInterfaceAndMethodName() { + await().atMost(Duration.ofSeconds(30)).untilAsserted(() -> { + State state = daprClient.getState( + STATE_STORE, "agents:" + TEAM + ":TestAgent.defaultNameAgent", AgentMetadataSchema.class).block(); + assertThat(state).isNotNull(); + assertThat(state.getValue()).isNotNull(); + + AgentMetadataSchema schema = state.getValue(); + assertThat(schema.getName()).isEqualTo("TestAgent.defaultNameAgent"); + assertThat(schema.getAgent().getGoal()).isEqualTo("Agent with default name"); + }); + } + + + + @Test + void allAutoDiscoveredAgentsShouldHaveConsistentMetadata() { + String[] expectedAgents = {"test-agent-with-prompt", "test-agent-simple", "TestAgent.defaultNameAgent"}; + + await().atMost(Duration.ofSeconds(30)).untilAsserted(() -> { + for (String agentName : expectedAgents) { + State state = daprClient.getState( + STATE_STORE, "agents:" + TEAM + ":" + agentName, AgentMetadataSchema.class).block(); + assertThat(state.getValue()) + .as("Agent '%s' should be registered", agentName) + .isNotNull(); + + AgentMetadataSchema schema = state.getValue(); + assertThat(schema.getSchemaVersion()).isEqualTo("0.11.1"); + assertThat(schema.getAgent().getAppId()).isEqualTo(APP_ID); + assertThat(schema.getAgent().getType()).isEqualTo("Agent"); + assertThat(schema.getAgent().getFramework()).isEqualTo("langchain4j"); + assertThat(schema.getRegisteredAt()).isNotBlank(); + } + }); + } + + @SuppressWarnings("unchecked") + @Test + void teamIndexShouldContainAllRegisteredAgents() { + String[] expectedAgents = {"test-agent-with-prompt", "test-agent-simple", "TestAgent.defaultNameAgent"}; + + await().atMost(Duration.ofSeconds(30)).untilAsserted(() -> { + State indexState = daprClient.getState( + STATE_STORE, "agents:" + TEAM + ":_index", TypeRef.get(Map.class)).block(); + assertThat(indexState).isNotNull(); + assertThat(indexState.getValue()).isNotNull(); + + Map indexData = (Map) indexState.getValue(); + assertThat(indexData).containsKey("agents"); + + List agentsList = (List) indexData.get("agents"); + // contains (not exactly): other test fixtures in this module also register agents + assertThat(agentsList).contains(expectedAgents); + }); + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-registry/src/test/java/io/dapr/quarkus/agents/registry/service/AgentRegistryTest.java b/quarkus/quarkus-langchain4j-dapr-registry/src/test/java/io/dapr/quarkus/agents/registry/service/AgentRegistryTest.java new file mode 100644 index 0000000000..c36b341ee0 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-registry/src/test/java/io/dapr/quarkus/agents/registry/service/AgentRegistryTest.java @@ -0,0 +1,190 @@ +package io.dapr.quarkus.agents.registry.service; + +import dev.langchain4j.agentic.Agent; +import dev.langchain4j.service.SystemMessage; +import io.dapr.quarkus.agents.registry.model.AgentMetadataSchema; +import org.junit.jupiter.api.Test; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class AgentRegistryTest { + + private static final String APP_ID = "test-app"; + + // --- Test interfaces --- + // Note: @Agent methods use no parameters to avoid validation errors from + // the langchain4j AgenticProcessor deployment step during @QuarkusTest runs. + + interface SimpleAgent { + @Agent(name = "my-agent", description = "A simple agent") + String chat(); + } + + interface AgentWithPrompts { + @Agent(name = "prompted-agent", description = "Agent with prompts") + @SystemMessage("You are a helpful assistant.") + String ask(); + } + + interface AgentWithDefaultName { + @Agent(description = "Agent with no explicit name") + String doWork(); + } + + interface NoAgentInterface { + String regularMethod(); + } + + interface MultipleAgentMethods { + @Agent(name = "agent-one", description = "First agent") + String first(); + + @Agent(name = "agent-two", description = "Second agent") + @SystemMessage("You are agent two.") + String second(); + } + + // --- Test annotation and interfaces for sub-agent discovery --- + + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.METHOD) + @interface MockSequenceAgent { + Class[] subAgents(); + } + + interface SubAgentA { + @Agent(name = "sub-agent-a", description = "Sub-agent A") + String run(); + } + + interface SubAgentB { + @Agent(name = "sub-agent-b", description = "Sub-agent B") + String run(); + } + + interface CompositeAgent { + @MockSequenceAgent(subAgents = { SubAgentA.class, SubAgentB.class }) + String orchestrate(); + } + + // --- Tests --- + + @Test + void simpleAgentDiscovery() { + List agents = AgentRegistry.scanForAgents(SimpleAgent.class, APP_ID); + + assertThat(agents).hasSize(1); + AgentMetadataSchema schema = agents.get(0); + assertThat(schema.getSchemaVersion()).isEqualTo("0.11.1"); + assertThat(schema.getName()).isEqualTo("my-agent"); + assertThat(schema.getAgent().getGoal()).isEqualTo("A simple agent"); + assertThat(schema.getAgent().getAppId()).isEqualTo(APP_ID); + assertThat(schema.getAgent().getType()).isEqualTo("Agent"); + assertThat(schema.getAgent().getFramework()).isEqualTo("langchain4j"); + assertThat(schema.getAgent().getSystemPrompt()).isNull(); + assertThat(schema.getRegisteredAt()).isNotBlank(); + } + + @Test + void agentWithPromptsExtractsSystemMessage() { + List agents = AgentRegistry.scanForAgents(AgentWithPrompts.class, APP_ID); + + assertThat(agents).hasSize(1); + AgentMetadataSchema schema = agents.get(0); + assertThat(schema.getName()).isEqualTo("prompted-agent"); + assertThat(schema.getAgent().getGoal()).isEqualTo("Agent with prompts"); + assertThat(schema.getAgent().getSystemPrompt()).isEqualTo("You are a helpful assistant."); + assertThat(schema.getAgent().getFramework()).isEqualTo("langchain4j"); + } + + @Test + void agentWithDefaultNameFallsBackToClassAndMethod() { + List agents = AgentRegistry.scanForAgents(AgentWithDefaultName.class, APP_ID); + + assertThat(agents).hasSize(1); + AgentMetadataSchema schema = agents.get(0); + assertThat(schema.getName()).isEqualTo("AgentWithDefaultName.doWork"); + assertThat(schema.getAgent().getGoal()).isEqualTo("Agent with no explicit name"); + } + + @Test + void noAgentInterfaceReturnsEmptyList() { + List agents = AgentRegistry.scanForAgents(NoAgentInterface.class, APP_ID); + + assertThat(agents).isEmpty(); + } + + @Test + void multipleAgentMethodsDiscoveredSeparately() { + List agents = AgentRegistry.scanForAgents(MultipleAgentMethods.class, APP_ID); + + assertThat(agents).hasSize(2); + assertThat(agents).extracting(AgentMetadataSchema::getName) + .containsExactlyInAnyOrder("agent-one", "agent-two"); + + AgentMetadataSchema agentTwo = agents.stream() + .filter(a -> "agent-two".equals(a.getName())) + .findFirst().orElseThrow(); + assertThat(agentTwo.getAgent().getSystemPrompt()).isEqualTo("You are agent two."); + assertThat(agentTwo.getAgent().getGoal()).isEqualTo("Second agent"); + + AgentMetadataSchema agentOne = agents.stream() + .filter(a -> "agent-one".equals(a.getName())) + .findFirst().orElseThrow(); + assertThat(agentOne.getAgent().getSystemPrompt()).isNull(); + assertThat(agentOne.getAgent().getGoal()).isEqualTo("First agent"); + } + + @Test + void allSchemasHaveCorrectVersionAndAppId() { + List agents = AgentRegistry.scanForAgents(MultipleAgentMethods.class, APP_ID); + + for (AgentMetadataSchema schema : agents) { + assertThat(schema.getSchemaVersion()).isEqualTo("0.11.1"); + assertThat(schema.getAgent().getAppId()).isEqualTo(APP_ID); + assertThat(schema.getAgent().getFramework()).isEqualTo("langchain4j"); + assertThat(schema.getAgent().getType()).isEqualTo("Agent"); + } + } + + @Test + void extractSubAgentClassesFromCompositeAnnotation() throws Exception { + java.lang.annotation.Annotation ann = CompositeAgent.class + .getDeclaredMethod("orchestrate") + .getDeclaredAnnotations()[0]; // @MockSequenceAgent + + Class[] subAgents = AgentRegistry.extractSubAgentClasses(ann); + + assertThat(subAgents).containsExactly(SubAgentA.class, SubAgentB.class); + } + + @Test + void extractSubAgentClassesReturnsEmptyForNonComposite() throws Exception { + java.lang.annotation.Annotation ann = SimpleAgent.class + .getDeclaredMethod("chat") + .getDeclaredAnnotations()[0]; // @Agent + + Class[] subAgents = AgentRegistry.extractSubAgentClasses(ann); + + assertThat(subAgents).isEmpty(); + } + + @Test + void scanForAgentsDiscoverSubAgentInterfaces() { + // SubAgentA and SubAgentB are not CDI beans, but their @Agent should be scannable + List agentsA = AgentRegistry.scanForAgents(SubAgentA.class, APP_ID); + List agentsB = AgentRegistry.scanForAgents(SubAgentB.class, APP_ID); + + assertThat(agentsA).hasSize(1); + assertThat(agentsA.get(0).getName()).isEqualTo("sub-agent-a"); + + assertThat(agentsB).hasSize(1); + assertThat(agentsB.get(0).getName()).isEqualTo("sub-agent-b"); + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-registry/src/test/java/io/dapr/quarkus/agents/registry/service/MockChatModel.java b/quarkus/quarkus-langchain4j-dapr-registry/src/test/java/io/dapr/quarkus/agents/registry/service/MockChatModel.java new file mode 100644 index 0000000000..d9a76cfd82 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-registry/src/test/java/io/dapr/quarkus/agents/registry/service/MockChatModel.java @@ -0,0 +1,30 @@ +package io.dapr.quarkus.agents.registry.service; + +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.output.FinishReason; +import dev.langchain4j.model.output.TokenUsage; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Alternative; + +/** + * Mock ChatModel that returns predictable responses for integration testing. + * Takes priority over the OpenAI ChatModel bean via {@code @Alternative @Priority(1)}. + */ +@Alternative +@Priority(1) +@ApplicationScoped +public class MockChatModel implements ChatModel { + + @Override + public ChatResponse doChat(ChatRequest request) { + return ChatResponse.builder() + .aiMessage(AiMessage.from("Mock response for testing.")) + .tokenUsage(new TokenUsage(5, 10)) + .finishReason(FinishReason.STOP) + .build(); + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-registry/src/test/java/io/dapr/quarkus/agents/registry/service/TestAgent.java b/quarkus/quarkus-langchain4j-dapr-registry/src/test/java/io/dapr/quarkus/agents/registry/service/TestAgent.java new file mode 100644 index 0000000000..3f65c44588 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-registry/src/test/java/io/dapr/quarkus/agents/registry/service/TestAgent.java @@ -0,0 +1,24 @@ +package io.dapr.quarkus.agents.registry.service; + +import dev.langchain4j.agentic.Agent; +import dev.langchain4j.service.SystemMessage; + +/** + * Test agent interface for verifying auto-discovery by {@link AgentRegistry}. + * Contains multiple {@code @Agent} methods covering different metadata combinations. + *

+ * Methods have no parameters to avoid validation errors from the langchain4j + * AgenticProcessor which expects parameters to resolve from agent output keys. + */ +public interface TestAgent { + + @Agent(name = "test-agent-with-prompt", description = "Agent with system prompt") + @SystemMessage("You are a test agent for integration testing.") + String chatWithPrompt(); + + @Agent(name = "test-agent-simple", description = "Simple agent without prompt") + String chatSimple(); + + @Agent(description = "Agent with default name") + String defaultNameAgent(); +} diff --git a/quarkus/quarkus-langchain4j-dapr-registry/src/test/java/io/dapr/quarkus/agents/registry/service/TestAgentBean.java b/quarkus/quarkus-langchain4j-dapr-registry/src/test/java/io/dapr/quarkus/agents/registry/service/TestAgentBean.java new file mode 100644 index 0000000000..d9f25034ce --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-registry/src/test/java/io/dapr/quarkus/agents/registry/service/TestAgentBean.java @@ -0,0 +1,30 @@ +package io.dapr.quarkus.agents.registry.service; + +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Alternative; + +/** + * CDI bean implementing {@link TestAgent} so that {@link AgentRegistry} + * can discover it via {@code BeanManager} during startup. + */ +@Alternative +@Priority(1) +@ApplicationScoped +public class TestAgentBean implements TestAgent { + + @Override + public String chatWithPrompt() { + return "mock response"; + } + + @Override + public String chatSimple() { + return "mock response"; + } + + @Override + public String defaultNameAgent() { + return "mock response"; + } +} diff --git a/quarkus/quarkus-langchain4j-dapr-registry/src/test/resources/application.properties b/quarkus/quarkus-langchain4j-dapr-registry/src/test/resources/application.properties new file mode 100644 index 0000000000..7a3cdf6d62 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr-registry/src/test/resources/application.properties @@ -0,0 +1,21 @@ +# Dapr Dev Services with PostgreSQL state store (dashboard enabled = uses PostgreSQL) +quarkus.dapr.devservices.enabled=true +quarkus.dapr.devservices.dashboard.enabled=true +# Match the Dapr runtime pinned by testcontainers-dapr 1.18.0 (canonical +# daprd/placement/scheduler images; Testcontainers rejects other tags) +quarkus.dapr.devservices.daprd-image=daprio/daprd:1.18.0 + +# Load gRPC/protobuf/Jackson parent-first: the Dapr SDK uses them across the +# @QuarkusTest classloader boundary (dev services + workflow runtime), otherwise +# startup fails with LinkageError (duplicate io.grpc.Channel / protobuf Message). +quarkus.class-loading.parent-first-artifacts=io.grpc:grpc-api,io.grpc:grpc-core,io.grpc:grpc-stub,io.grpc:grpc-protobuf,io.grpc:grpc-protobuf-lite,io.grpc:grpc-netty-shaded,io.grpc:grpc-context,io.grpc:grpc-util,com.fasterxml.jackson.core:jackson-databind,com.fasterxml.jackson.core:jackson-core,com.fasterxml.jackson.core:jackson-annotations,com.fasterxml.jackson.datatype:jackson-datatype-jsr310,com.google.protobuf:protobuf-java,com.google.protobuf:protobuf-java-util,com.google.api.grpc:proto-google-common-protos + +# Agent registry configuration +# Dev services name the state store component "kvstore" +dapr.agents.statestore=kvstore +dapr.appid=local-dapr-app +dapr.agents.team=test-team + +# Dummy OpenAI key (MockChatModel overrides the real provider in tests) +quarkus.langchain4j.openai.api-key=test-key +quarkus.langchain4j.openai.chat-model.model-name=gpt-4o-mini diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml index 98645d005f..d8237ca92a 100644 --- a/spotbugs-exclude.xml +++ b/spotbugs-exclude.xml @@ -63,4 +63,18 @@ + + + + + + + + + + + + + + \ No newline at end of file