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-daprdurabletask-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.
+ *
+ *
+ */
+@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.
+ *
+ *
+ */
+@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.
+ *
+ *
+ */
+@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:
+ *
+ *
Runs the {@link ResearchWriter} agent's ReAct loop as a {@code react-agent} workflow.
+ *
Each LLM tool call ({@code getPopulation} / {@code getCapital}) is executed as an
+ * {@code agent-tool} Dapr Workflow Activity.
+ */
+@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.
+ *
+ *
+ */
+@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:
+ *
+ *
Producing an {@link IndexDependencyBuildItem} so our runtime JAR is indexed into the
+ * {@link CombinedIndexBuildItem} (and visible to Arc for CDI bean discovery).
+ *
Registering our workflows and activities with the Dapr workflow runtime
+ * ({@link #setupWorkflowRuntime}) and producing {@link AdditionalBeanBuildItem}s so Arc
+ * discovers them as CDI beans.
+ *
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.
+ *
+ */
+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