From 7260af8553f8328ce7f4286c295ae32433f1b5aa Mon Sep 17 00:00:00 2001 From: salaboy Date: Tue, 10 Mar 2026 14:47:16 +0000 Subject: [PATCH 01/21] feat(quarkus): add quarkus-agentic-dapr extension Quarkus extension bridging LangChain4j agents with Dapr Workflows. Every LLM call and tool call becomes a durable Dapr activity. Includes runtime/deployment modules, agent registry, CI workflows, orchestration (sequential, parallel, loop, conditional), and built-in examples. NEWMSG "$@" Signed-off-by: Javier Aliaga --- pom.xml | 1 + quarkus/README.md | 302 ++++++++ quarkus/deployment/pom.xml | 60 ++ .../deployment/DaprAgenticProcessor.java | 647 ++++++++++++++++++ quarkus/examples/pom.xml | 95 +++ .../dapr/examples/CreativeWriter.java | 35 + .../dapr/examples/ParallelCreator.java | 50 ++ .../dapr/examples/ParallelResource.java | 49 ++ .../dapr/examples/ParallelStatus.java | 17 + .../dapr/examples/ResearchResource.java | 55 ++ .../dapr/examples/ResearchTools.java | 70 ++ .../dapr/examples/ResearchWriter.java | 48 ++ .../dapr/examples/StoryCreator.java | 32 + .../dapr/examples/StoryResource.java | 45 ++ .../dapr/examples/StyleEditor.java | 34 + .../src/main/resources/application.properties | 26 + .../dapr/examples/DaprWorkflowClientTest.java | 28 + .../examples/DockerAvailableCondition.java | 48 ++ .../dapr/examples/MockChatModel.java | 32 + .../dapr/examples/ParallelResourceTest.java | 51 ++ .../dapr/examples/StoryResourceTest.java | 59 ++ .../src/test/resources/application.properties | 8 + quarkus/pom.xml | 82 +++ .../pom.xml | 92 +++ .../agents/registry/model/AgentMetadata.java | 172 +++++ .../registry/model/AgentMetadataSchema.java | 232 +++++++ .../agents/registry/model/LlmMetadata.java | 170 +++++ .../agents/registry/model/MemoryMetadata.java | 72 ++ .../agents/registry/model/PubSubMetadata.java | 86 +++ .../registry/model/RegistryMetadata.java | 64 ++ .../agents/registry/model/ToolMetadata.java | 86 +++ .../registry/service/AgentRegistry.java | 267 ++++++++ .../src/main/resources/META-INF/beans.xml | 6 + .../src/main/resources/schema.json | 461 +++++++++++++ .../model/AgentMetadataSchemaTest.java | 404 +++++++++++ .../service/AgentRegistryDevServicesTest.java | 120 ++++ .../registry/service/AgentRegistryTest.java | 190 +++++ .../registry/service/MockChatModel.java | 30 + .../agents/registry/service/TestAgent.java | 24 + .../registry/service/TestAgentBean.java | 30 + .../src/test/resources/application.properties | 13 + quarkus/runtime/pom.xml | 88 +++ .../langchain4j/agent/AgentRunContext.java | 146 ++++ .../agent/AgentRunLifecycleManager.java | 137 ++++ .../agent/DaprAgentContextHolder.java | 54 ++ .../agent/DaprAgentInterceptorBinding.java | 39 ++ .../agent/DaprAgentMetadataHolder.java | 63 ++ .../agent/DaprAgentMethodInterceptor.java | 139 ++++ .../agent/DaprAgentRunRegistry.java | 72 ++ .../DaprAgentToolInterceptorBinding.java | 37 + .../agent/DaprChatModelDecorator.java | 272 ++++++++ .../agent/DaprToolCallInterceptor.java | 145 ++++ .../agent/activities/LlmCallActivity.java | 134 ++++ .../agent/activities/LlmCallInput.java | 30 + .../agent/activities/LlmCallOutput.java | 29 + .../agent/activities/ToolCallActivity.java | 100 +++ .../agent/activities/ToolCallInput.java | 28 + .../agent/activities/ToolCallOutput.java | 26 + .../agent/workflow/AgentEvent.java | 36 + .../agent/workflow/AgentRunInput.java | 31 + .../agent/workflow/AgentRunOutput.java | 47 ++ .../agent/workflow/AgentRunWorkflow.java | 126 ++++ .../memory/KeyValueChatMemoryStore.java | 91 +++ .../workflow/DaprAgentService.java | 29 + .../workflow/DaprAgentServiceUtil.java | 38 + .../workflow/DaprConditionalAgentService.java | 155 +++++ .../workflow/DaprLoopAgentService.java | 158 +++++ .../workflow/DaprParallelAgentService.java | 88 +++ .../workflow/DaprPlannerRegistry.java | 64 ++ .../workflow/DaprSequentialAgentService.java | 88 +++ .../workflow/DaprWorkflowAgentsBuilder.java | 82 +++ .../workflow/DaprWorkflowPlanner.java | 448 ++++++++++++ .../workflow/WorkflowNameResolver.java | 42 ++ .../orchestration/AgentExecInput.java | 25 + .../orchestration/ConditionCheckInput.java | 23 + .../ConditionalOrchestrationWorkflow.java | 67 ++ .../ExitConditionCheckInput.java | 23 + .../LoopOrchestrationWorkflow.java | 86 +++ .../orchestration/OrchestrationInput.java | 25 + .../ParallelOrchestrationWorkflow.java | 76 ++ .../SequentialOrchestrationWorkflow.java | 62 ++ .../activities/AgentExecutionActivity.java | 81 +++ .../activities/ConditionCheckActivity.java | 41 ++ .../ExitConditionCheckActivity.java | 41 ++ .../resources/META-INF/quarkus-extension.yaml | 11 + ...n4j.agentic.workflow.WorkflowAgentsBuilder | 1 + .../memory/KeyValueChatMemoryStoreTest.java | 195 ++++++ .../workflow/DaprAgentServiceUtilTest.java | 38 + .../workflow/DaprPlannerRegistryTest.java | 52 ++ .../DaprWorkflowAgentsBuilderTest.java | 95 +++ .../workflow/DaprWorkflowPlannerTest.java | 291 ++++++++ .../orchestration/ActivitiesTest.java | 174 +++++ .../orchestration/InputRecordsTest.java | 51 ++ .../orchestration/OrchestrationInputTest.java | 35 + spotbugs-exclude.xml | 14 + 95 files changed, 8962 insertions(+) create mode 100644 quarkus/README.md create mode 100644 quarkus/deployment/pom.xml create mode 100644 quarkus/deployment/src/main/java/io/quarkiverse/dapr/langchain4j/deployment/DaprAgenticProcessor.java create mode 100644 quarkus/examples/pom.xml create mode 100644 quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/CreativeWriter.java create mode 100644 quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelCreator.java create mode 100644 quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelResource.java create mode 100644 quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelStatus.java create mode 100644 quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchResource.java create mode 100644 quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchTools.java create mode 100644 quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchWriter.java create mode 100644 quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StoryCreator.java create mode 100644 quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StoryResource.java create mode 100644 quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StyleEditor.java create mode 100644 quarkus/examples/src/main/resources/application.properties create mode 100644 quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/DaprWorkflowClientTest.java create mode 100644 quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/DockerAvailableCondition.java create mode 100644 quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/MockChatModel.java create mode 100644 quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/ParallelResourceTest.java create mode 100644 quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/StoryResourceTest.java create mode 100644 quarkus/examples/src/test/resources/application.properties create mode 100644 quarkus/pom.xml create mode 100644 quarkus/quarkus-agentic-dapr-agents-registry/pom.xml create mode 100644 quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/AgentMetadata.java create mode 100644 quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/AgentMetadataSchema.java create mode 100644 quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/LlmMetadata.java create mode 100644 quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/MemoryMetadata.java create mode 100644 quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/PubSubMetadata.java create mode 100644 quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/RegistryMetadata.java create mode 100644 quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/ToolMetadata.java create mode 100644 quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/service/AgentRegistry.java create mode 100644 quarkus/quarkus-agentic-dapr-agents-registry/src/main/resources/META-INF/beans.xml create mode 100644 quarkus/quarkus-agentic-dapr-agents-registry/src/main/resources/schema.json create mode 100644 quarkus/quarkus-agentic-dapr-agents-registry/src/test/java/io/quarkiverse/dapr/agents/registry/model/AgentMetadataSchemaTest.java create mode 100644 quarkus/quarkus-agentic-dapr-agents-registry/src/test/java/io/quarkiverse/dapr/agents/registry/service/AgentRegistryDevServicesTest.java create mode 100644 quarkus/quarkus-agentic-dapr-agents-registry/src/test/java/io/quarkiverse/dapr/agents/registry/service/AgentRegistryTest.java create mode 100644 quarkus/quarkus-agentic-dapr-agents-registry/src/test/java/io/quarkiverse/dapr/agents/registry/service/MockChatModel.java create mode 100644 quarkus/quarkus-agentic-dapr-agents-registry/src/test/java/io/quarkiverse/dapr/agents/registry/service/TestAgent.java create mode 100644 quarkus/quarkus-agentic-dapr-agents-registry/src/test/java/io/quarkiverse/dapr/agents/registry/service/TestAgentBean.java create mode 100644 quarkus/quarkus-agentic-dapr-agents-registry/src/test/resources/application.properties create mode 100644 quarkus/runtime/pom.xml create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/AgentRunContext.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/AgentRunLifecycleManager.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentContextHolder.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentInterceptorBinding.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentMetadataHolder.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentMethodInterceptor.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentRunRegistry.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentToolInterceptorBinding.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprChatModelDecorator.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprToolCallInterceptor.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/LlmCallActivity.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/LlmCallInput.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/LlmCallOutput.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/ToolCallActivity.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/ToolCallInput.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/ToolCallOutput.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/workflow/AgentEvent.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/workflow/AgentRunInput.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/workflow/AgentRunOutput.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/workflow/AgentRunWorkflow.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/memory/KeyValueChatMemoryStore.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprAgentService.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprAgentServiceUtil.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprConditionalAgentService.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprLoopAgentService.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprParallelAgentService.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprPlannerRegistry.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprSequentialAgentService.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprWorkflowAgentsBuilder.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprWorkflowPlanner.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/WorkflowNameResolver.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/AgentExecInput.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ConditionCheckInput.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ConditionalOrchestrationWorkflow.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ExitConditionCheckInput.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/LoopOrchestrationWorkflow.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/OrchestrationInput.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ParallelOrchestrationWorkflow.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/SequentialOrchestrationWorkflow.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/activities/AgentExecutionActivity.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/activities/ConditionCheckActivity.java create mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/activities/ExitConditionCheckActivity.java create mode 100644 quarkus/runtime/src/main/resources/META-INF/quarkus-extension.yaml create mode 100644 quarkus/runtime/src/main/resources/META-INF/services/dev.langchain4j.agentic.workflow.WorkflowAgentsBuilder create mode 100644 quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/memory/KeyValueChatMemoryStoreTest.java create mode 100644 quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/DaprAgentServiceUtilTest.java create mode 100644 quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/DaprPlannerRegistryTest.java create mode 100644 quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/DaprWorkflowAgentsBuilderTest.java create mode 100644 quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/DaprWorkflowPlannerTest.java create mode 100644 quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ActivitiesTest.java create mode 100644 quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/InputRecordsTest.java create mode 100644 quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/OrchestrationInputTest.java diff --git a/pom.xml b/pom.xml index f039e6254c..ecbfb78416 100644 --- a/pom.xml +++ b/pom.xml @@ -749,6 +749,7 @@ testcontainers-dapr durabletask-client + quarkus diff --git a/quarkus/README.md b/quarkus/README.md new file mode 100644 index 0000000000..f31ddd4ed5 --- /dev/null +++ b/quarkus/README.md @@ -0,0 +1,302 @@ +# Quarkus Agentic Dapr Examples + +This module demonstrates how to use `quarkus-agentic-dapr` to orchestrate LangChain4j agents with [Dapr Workflows](https://docs.dapr.io/developing-applications/building-blocks/workflow/workflow-overview/) as the underlying execution engine. + +## What is Quarkus Agentic Dapr? + +`quarkus-agentic-dapr` is a Quarkus extension that bridges LangChain4j's agentic framework with Dapr Durable Workflows. It is a **drop-in replacement** for the `quarkus-langchain4j-agentic` module — your agent definitions, annotations, and application code stay exactly the same. The only thing that changes is the dependency in your `pom.xml`. + +### In-Memory vs Dapr Orchestration + +LangChain4j ships with `quarkus-langchain4j-agentic`, which provides in-memory orchestration of agents using JVM thread pools. This works well for development and simple use cases, but has limitations in production: + +- **No durability** — if the process crashes, all in-flight agent work is lost. +- **No observability** — agent execution state lives only in memory; there is no history of what happened. +- **No fault tolerance** — a failed tool call or LLM call requires restarting the entire flow from scratch. +- **Single JVM only** — orchestration cannot span multiple service instances. + +`quarkus-agentic-dapr` solves these problems by routing every orchestration decision, tool call, and LLM call through Dapr Workflow activities. This gives you: + +| Capability | `quarkus-langchain4j-agentic` | `quarkus-agentic-dapr` | +|---|---|---| +| Durability | None — lost on crash | Full workflow history persisted by Dapr | +| Observability | Application logs only | Dapr dashboard + per-activity status tracking | +| Fault tolerance | Manual retry | Dapr auto-retries failed activities | +| Scalability | Single JVM thread pool | Distributed across Dapr sidecars | +| Tool call audit trail | Log-based | Every tool call recorded in workflow history with inputs/outputs | +| Code changes required | None | **None** — just swap the dependency | + +### How Dapr Workflows Work Behind the Scenes + +When you use `quarkus-agentic-dapr`, your agent orchestration is backed by a hierarchy of Dapr Workflows: + +1. **Orchestration Workflow** (top level) — one of `SequentialOrchestrationWorkflow`, `ParallelOrchestrationWorkflow`, `LoopOrchestrationWorkflow`, or `ConditionalOrchestrationWorkflow`. This coordinates the order in which agents execute. + +2. **AgentRunWorkflow** (per agent) — each agent gets its own child workflow that manages its full ReAct loop lifecycle. It listens for events like `"tool-call"`, `"llm-call"`, and `"done"`, scheduling the appropriate Dapr activities for each. + +3. **Activities** (individual operations) — `ToolCallActivity` executes tool methods, `LlmCallActivity` executes LLM calls. Each activity is a durable unit of work that Dapr can retry on failure and that appears in the workflow history. + +This architecture means that every decision the LLM makes, every tool it calls, and every result it receives is recorded as a durable workflow event. If the process crashes mid-execution, Dapr replays the workflow from the last checkpoint — no work is lost. + +The routing is completely transparent. At build time, the Quarkus extension automatically applies interceptors to all `@Tool`-annotated methods and generates CDI decorators for all `@Agent` interfaces. No changes to your agent code are required. + +### Java SPI Discovery + +The integration uses Java SPI (Service Provider Interface) to register itself. When LangChain4j encounters `@SequenceAgent`, `@ParallelAgent`, `@LoopAgent`, or `@ConditionalAgent`, it discovers `DaprWorkflowAgentsBuilder` via SPI and uses Dapr-based planners instead of in-memory ones. This is why the swap is transparent — just adding the dependency is enough. + +## Prerequisites + +- Java 17+ +- Maven 3.9+ +- An OpenAI API key (or compatible LLM provider) + +No separate Dapr installation is needed for development — the extension uses **Quarkus Dapr Dev Services** to automatically start a Dapr sidecar, placement service, scheduler, and state store when you run in dev mode. + +## Project Setup + +### Dependency + +Replace `quarkus-langchain4j-agentic` with `quarkus-agentic-dapr` in your `pom.xml`: + +```xml + + + io.quarkiverse.langchain4j + quarkus-langchain4j-agentic + + + + + io.dapr.quarkus + quarkus-agentic-dapr + 1.18.0-SNAPSHOT + +``` + +You also need an LLM provider and a REST endpoint: + +```xml + + io.quarkiverse.langchain4j + quarkus-langchain4j-openai + + + io.quarkus + quarkus-rest + +``` + +Optionally, add the agent registry to auto-register agent metadata in a Dapr state store: + +```xml + + io.dapr.quarkus + quarkus-agentic-dapr-agents-registry + 1.18.0-SNAPSHOT + +``` + +### Configuration + +Add to `application.properties`: + +```properties +# Enable Dapr Dev Services (auto-starts sidecar, placement, scheduler, state store) +quarkus.dapr.devservices.enabled=true +quarkus.dapr.workflow.enabled=true + +# LLM provider configuration +quarkus.langchain4j.openai.api-key=${OPENAI_API_KEY} +quarkus.langchain4j.openai.chat-model.model-name=gpt-4o-mini +``` + +## Examples + +### 1. Sequential Agent — Story Creator + +A composite agent that runs two sub-agents in sequence: first a `CreativeWriter` generates a story draft, then a `StyleEditor` refines it. + +**Agent definitions:** + +```java +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); +} + +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); +} +``` + +**Orchestration:** + +```java +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); +} +``` + +**REST endpoint:** + +```java +@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); + } +} +``` + +Behind the scenes, this starts a `SequentialOrchestrationWorkflow` in Dapr that runs `CreativeWriter` first, passes the `story` output to `StyleEditor`, and returns the final result. + +**Try it:** + +```bash +curl "http://localhost:8080/story?topic=dragons&style=comedy" +``` + +### 2. Parallel Agent — Story + Research + +A composite agent that runs a `StoryCreator` (itself a sequential agent) and a `ResearchWriter` in parallel. This demonstrates nested composite agents. + +```java +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); + + @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); + } +} +``` + +Behind the scenes, a `ParallelOrchestrationWorkflow` spawns both sub-agents concurrently using Dapr's `allOf()` task composition and waits for all to complete. + +**Try it:** + +```bash +curl "http://localhost:8080/parallel?topic=dragons&country=France&style=comedy" +``` + +### 3. Standalone Agent with Tool Calls — Research Writer + +An agent that uses `@Tool`-annotated methods to fetch data. Tool calls are automatically routed through `ToolCallActivity` Dapr activities. + +```java +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); +} +``` + +The tools are plain CDI beans — no Dapr-specific code needed: + +```java +@ApplicationScoped +public class ResearchTools { + + @Tool("Looks up real-time population data for a given country") + public String getPopulation(String country) { + return switch (country.toLowerCase()) { + case "france" -> "France has approximately 68 million inhabitants (2024)."; + case "germany" -> "Germany has approximately 84 million inhabitants (2024)."; + default -> country + " population data is not available in this demo."; + }; + } + + @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."; + default -> "Capital city for " + country + " is not available in this demo."; + }; + } +} +``` + +When the LLM decides to call `getPopulation` or `getCapital`, the Dapr extension intercepts the call and executes it as a `ToolCallActivity`. This means every tool invocation is recorded in the Dapr workflow history and can be retried automatically on failure. + +**Try it:** + +```bash +curl "http://localhost:8080/research?country=France" +``` + +## Running the Examples + +1. Set your OpenAI API key: + +```bash +export OPENAI_API_KEY=sk-... +``` + +2. Start in Quarkus dev mode (Dapr Dev Services will start automatically): + +```bash +cd quarkus/examples +mvn quarkus:dev +``` + +3. Call the endpoints: + +```bash +# Sequential story creation +curl "http://localhost:8080/story?topic=space+exploration&style=noir" + +# Parallel story + research +curl "http://localhost:8080/parallel?topic=robots&country=Japan&style=sci-fi" + +# Standalone research with tool calls +curl "http://localhost:8080/research?country=Germany" +``` + +## Summary + +`quarkus-agentic-dapr` lets you keep writing standard LangChain4j agent code while gaining the production-grade durability, observability, and fault tolerance of Dapr Workflows. Swap the dependency, add two lines of configuration, and your agents are now backed by durable workflows — no code changes required. \ No newline at end of file diff --git a/quarkus/deployment/pom.xml b/quarkus/deployment/pom.xml new file mode 100644 index 0000000000..bb75c756d0 --- /dev/null +++ b/quarkus/deployment/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + io.dapr.quarkus + dapr-quarkus-agentic-parent + 1.18.0-SNAPSHOT + ../pom.xml + + + quarkus-agentic-dapr-deployment + Quarkus Agentic Dapr - 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-agentic-dapr + ${project.version} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + diff --git a/quarkus/deployment/src/main/java/io/quarkiverse/dapr/langchain4j/deployment/DaprAgenticProcessor.java b/quarkus/deployment/src/main/java/io/quarkiverse/dapr/langchain4j/deployment/DaprAgenticProcessor.java new file mode 100644 index 0000000000..689a6fd819 --- /dev/null +++ b/quarkus/deployment/src/main/java/io/quarkiverse/dapr/langchain4j/deployment/DaprAgenticProcessor.java @@ -0,0 +1,647 @@ +/* + * 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.quarkiverse.dapr.langchain4j.deployment; + +import io.quarkiverse.dapr.deployment.items.WorkflowItemBuildItem; +import io.quarkiverse.dapr.langchain4j.agent.AgentRunLifecycleManager; +import io.quarkiverse.dapr.langchain4j.agent.DaprAgentMetadataHolder; +import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.arc.deployment.AnnotationsTransformerBuildItem; +import io.quarkus.arc.deployment.GeneratedBeanBuildItem; +import io.quarkus.arc.deployment.GeneratedBeanGizmoAdaptor; +import io.quarkus.arc.processor.AnnotationsTransformer; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.builditem.CombinedIndexBuildItem; +import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.IndexDependencyBuildItem; +import io.quarkus.gizmo.CatchBlockCreator; +import io.quarkus.gizmo.ClassCreator; +import io.quarkus.gizmo.ClassOutput; +import io.quarkus.gizmo.FieldCreator; +import io.quarkus.gizmo.FieldDescriptor; +import io.quarkus.gizmo.MethodCreator; +import io.quarkus.gizmo.MethodDescriptor; +import io.quarkus.gizmo.ResultHandle; +import io.quarkus.gizmo.TryBlock; +import jakarta.annotation.Priority; +import jakarta.decorator.Decorator; +import jakarta.decorator.Delegate; +import jakarta.enterprise.context.Dependent; +import jakarta.enterprise.inject.Any; +import jakarta.inject.Inject; +import jakarta.interceptor.Interceptor; +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.lang.reflect.Modifier; +import java.util.HashSet; +import java.util.Set; + +/** + * Quarkus deployment processor for the Dapr Agentic extension. + * + *

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

    + *
  1. Produce an {@link IndexDependencyBuildItem} so our runtime JAR is indexed into + * the {@link CombinedIndexBuildItem} (and visible to Arc for CDI bean discovery).
  2. + *
  3. Consume the {@link CombinedIndexBuildItem}, look up our Workflow and WorkflowActivity + * classes, and produce {@link WorkflowItemBuildItem} instances that the existing + * {@code DaprWorkflowProcessor} build steps consume to register with the Dapr + * workflow runtime.
  4. + *
  5. Produce {@link AdditionalBeanBuildItem} instances so Arc explicitly discovers + * our Workflow and WorkflowActivity classes as CDI beans.
  6. + *
  7. Apply {@code @DaprAgentToolInterceptorBinding} to all {@code @Tool}-annotated + * methods automatically so that {@code DaprToolCallInterceptor} routes those calls + * through Dapr Workflow Activities without requiring user code changes.
  8. + *
  9. Generate a CDI {@code @Decorator} for every {@code @Agent}-annotated interface + * so the {@link AgentRunLifecycleManager} workflow is started at the very beginning + * of the agent method call -- before LangChain4j assembles the prompt -- giving Dapr + * full observability of the agent's lifecycle from its first instruction.
  10. + *
+ */ +public class DaprAgenticProcessor { + + private static final Logger LOG = Logger.getLogger(DaprAgenticProcessor.class); + + private static final String FEATURE = "dapr-agentic"; + + /** + * Generated decorator classes live in this package to avoid polluting user packages. + */ + private static final String DECORATOR_PACKAGE = "io.quarkiverse.dapr.langchain4j.generated"; + + /** + * LangChain4j {@code @Tool} annotation (on CDI bean methods). + */ + private static final DotName TOOL_ANNOTATION = + DotName.createSimple("dev.langchain4j.agent.tool.Tool"); + + /** + * 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"); + + /** + * Our interceptor binding that triggers {@code DaprToolCallInterceptor}. + */ + private static final DotName DAPR_TOOL_INTERCEPTOR_BINDING = DotName.createSimple( + "io.quarkiverse.dapr.langchain4j.agent.DaprAgentToolInterceptorBinding"); + + /** + * Our interceptor binding that triggers {@code DaprAgentMethodInterceptor}. + */ + private static final DotName DAPR_AGENT_INTERCEPTOR_BINDING = DotName.createSimple( + "io.quarkiverse.dapr.langchain4j.agent.DaprAgentInterceptorBinding"); + + /** + * {@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"); + + private static final String[] WORKFLOW_CLASSES = { + "io.quarkiverse.dapr.langchain4j.workflow.orchestration.SequentialOrchestrationWorkflow", + "io.quarkiverse.dapr.langchain4j.workflow.orchestration.ParallelOrchestrationWorkflow", + "io.quarkiverse.dapr.langchain4j.workflow.orchestration.LoopOrchestrationWorkflow", + "io.quarkiverse.dapr.langchain4j.workflow.orchestration.ConditionalOrchestrationWorkflow", + // Per-agent workflow (one per @Agent invocation) + "io.quarkiverse.dapr.langchain4j.agent.workflow.AgentRunWorkflow", + }; + + private static final String[] ACTIVITY_CLASSES = { + "io.quarkiverse.dapr.langchain4j.workflow.orchestration.activities.AgentExecutionActivity", + "io.quarkiverse.dapr.langchain4j.workflow.orchestration.activities.ExitConditionCheckActivity", + "io.quarkiverse.dapr.langchain4j.workflow.orchestration.activities.ConditionCheckActivity", + // Per-tool-call activity + "io.quarkiverse.dapr.langchain4j.agent.activities.ToolCallActivity", + // Per-LLM-call activity + "io.quarkiverse.dapr.langchain4j.agent.activities.LlmCallActivity", + }; + + @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-agentic-dapr"); + } + + /** + * Produce {@link WorkflowItemBuildItem} for each of our Workflow and WorkflowActivity + * classes. + */ + @BuildStep + void registerWorkflowsAndActivities(CombinedIndexBuildItem combinedIndex, + BuildProducer workflowItems) { + IndexView index = combinedIndex.getIndex(); + + for (String className : WORKFLOW_CLASSES) { + ClassInfo classInfo = index.getClassByName(DotName.createSimple(className)); + if (classInfo != null) { + String regName = null; + String version = null; + Boolean isLatest = null; + AnnotationInstance meta = classInfo.annotation(WORKFLOW_METADATA_DOTNAME); + if (meta != null) { + regName = stringValueOrNull(meta, "name"); + version = stringValueOrNull(meta, "version"); + AnnotationValue isLatestVal = meta.value("isLatest"); + if (isLatestVal != null) { + isLatest = isLatestVal.asBoolean(); + } + } + workflowItems.produce(new WorkflowItemBuildItem( + classInfo, WorkflowItemBuildItem.Type.WORKFLOW, regName, version, isLatest)); + } + } + + for (String className : ACTIVITY_CLASSES) { + ClassInfo classInfo = index.getClassByName(DotName.createSimple(className)); + if (classInfo != null) { + String regName = null; + AnnotationInstance meta = classInfo.annotation(ACTIVITY_METADATA_DOTNAME); + if (meta != null) { + regName = stringValueOrNull(meta, "name"); + } + workflowItems.produce(new WorkflowItemBuildItem( + classInfo, WorkflowItemBuildItem.Type.WORKFLOW_ACTIVITY, regName, null, null)); + } + } + } + + /** + * Explicitly register our Workflow, WorkflowActivity, and CDI interceptor classes as 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)); + } + // AgentRunLifecycleManager is injected by generated decorators and must be discoverable. + additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf( + AgentRunLifecycleManager.class.getName())); + // CDI interceptors must be registered as unremovable beans. + additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf( + "io.quarkiverse.dapr.langchain4j.agent.DaprToolCallInterceptor")); + additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf( + "io.quarkiverse.dapr.langchain4j.agent.DaprAgentMethodInterceptor")); + // CDI decorator that wraps ChatModel to route LLM calls through Dapr activities. + // A decorator is used instead of an interceptor because quarkus-langchain4j registers + // ChatModel as a synthetic bean, and Arc does not apply CDI interceptors to synthetic + // beans via AnnotationsTransformer -- but it DOES apply decorators at the type level. + additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf( + "io.quarkiverse.dapr.langchain4j.agent.DaprChatModelDecorator")); + } + + /** + * Generates a CDI {@code @Decorator} for every interface that declares at least one + * {@code @Agent}-annotated method. + * + *

Why a generated decorator?

+ * quarkus-langchain4j registers {@code @Agent} AiService beans as synthetic beans + * (via {@code SyntheticBeanBuildItem}) -- CDI interceptors applied via + * {@code AnnotationsTransformer} are silently ignored on synthetic beans. CDI + * decorators, however, are matched at the bean type level and are applied + * by Arc to all beans (including synthetic beans) whose type includes the delegate type. + * This is the same mechanism used by + * {@link io.quarkiverse.dapr.langchain4j.agent.DaprChatModelDecorator} + * to wrap the synthetic {@code ChatModel} bean. + * + *

What the generated decorator does

+ * For each interface {@code I} with at least one {@code @Agent} method, Gizmo emits a class + * equivalent to: + *
{@code
+   * @Decorator @Priority(APPLICATION) @Dependent
+   * class DaprDecorator_I implements I {
+   *   @Inject @Delegate @Any I delegate;
+   *   @Inject AgentRunLifecycleManager lifecycleManager;
+   *
+   *   @Override
+   *   ReturnType agentMethod(Params...) {
+   *     lifecycleManager.getOrActivate(agentName, userMessage, systemMessage);
+   *     try {
+   *       ReturnType result = delegate.agentMethod(params);
+   *       lifecycleManager.triggerDone();
+   *       return result;
+   *     } catch (Throwable t) {
+   *       lifecycleManager.triggerDone();
+   *       throw t;
+   *     }
+   *   }
+   *   // non-@Agent abstract methods: pure delegation to delegate
+   * }
+   * }
+ * + *

Non-{@code @Agent} abstract methods are delegated transparently. Static and default + * (non-abstract) interface methods are not overridden. + */ + @BuildStep + void generateAgentDecorators( + CombinedIndexBuildItem combinedIndex, + BuildProducer generatedBeans) { + + IndexView index = combinedIndex.getIndex(); + ClassOutput classOutput = new GeneratedBeanGizmoAdaptor(generatedBeans); + + Set processedInterfaces = new HashSet<>(); + + for (AnnotationInstance agentAnnotation : index.getAnnotations(AGENT_ANNOTATION)) { + if (agentAnnotation.target().kind() != AnnotationTarget.Kind.METHOD) { + continue; + } + + ClassInfo declaringClass = agentAnnotation.target().asMethod().declaringClass(); + + // Only generate decorators for interfaces. + // CDI bean classes with @Agent methods are handled by DaprAgentMethodInterceptor. + if (!declaringClass.isInterface()) { + continue; + } + + if (!processedInterfaces.add(declaringClass.name())) { + continue; // one decorator per interface + } + + generateDecorator(classOutput, index, declaringClass); + } + } + + // ------------------------------------------------------------------------- + // Decorator generation helpers + // ------------------------------------------------------------------------- + + private void generateDecorator(ClassOutput classOutput, IndexView index, + ClassInfo agentInterface) { + String interfaceName = agentInterface.name().toString(); + + // Use the fully-qualified interface name (dots replaced by underscores) so two + // interfaces with the same simple name in different packages never collide. + String decoratorClassName = DECORATOR_PACKAGE + ".DaprDecorator_" + + interfaceName.replace('.', '_'); + + LOG.debugf("Generating CDI decorator %s for @Agent interface %s", + decoratorClassName, interfaceName); + + try (ClassCreator cc = ClassCreator.builder() + .classOutput(classOutput) + .className(decoratorClassName) + .interfaces(interfaceName) + .build()) { + + // --- class-level CDI annotations --- + cc.addAnnotation(Decorator.class); + cc.addAnnotation(Dependent.class); + cc.addAnnotation(Priority.class) + .addValue("value", Interceptor.Priority.APPLICATION); + + // --- @Inject @Delegate @Any InterfaceType delegate --- + FieldCreator delegateField = cc.getFieldCreator("delegate", interfaceName); + delegateField.setModifiers(Modifier.PROTECTED); + delegateField.addAnnotation(Inject.class); + delegateField.addAnnotation(Delegate.class); + delegateField.addAnnotation(Any.class); + + // --- @Inject AgentRunLifecycleManager lifecycleManager --- + FieldCreator lcmField = cc.getFieldCreator("lifecycleManager", + AgentRunLifecycleManager.class.getName()); + lcmField.setModifiers(Modifier.PRIVATE); + lcmField.addAnnotation(Inject.class); + + FieldDescriptor delegateDesc = delegateField.getFieldDescriptor(); + FieldDescriptor lcmDesc = lcmField.getFieldDescriptor(); + + // --- method overrides --- + // Collect all abstract methods declared directly on this interface. + // Inherited abstract methods from parent interfaces are intentionally skipped: + // CDI decorators are allowed to be "partial" (not implement every inherited + // method); Arc will delegate un-overridden abstract methods to the next + // decorator/bean in the chain automatically. + for (MethodInfo method : agentInterface.methods()) { + // Skip static and default (non-abstract) interface methods. + if (Modifier.isStatic(method.flags()) + || !Modifier.isAbstract(method.flags())) { + continue; + } + + if (method.hasAnnotation(AGENT_ANNOTATION)) { + generateDecoratedAgentMethod(cc, method, delegateDesc, lcmDesc); + } else { + generateDelegateMethod(cc, method, delegateDesc); + } + } + } + } + + /** + * Generates the body for an {@code @Agent}-annotated method. + *

+   *   lifecycleManager.getOrActivate(agentName, userMsg, sysMsg);
+   *   try {
+   *     [result =] delegate.method(params);
+   *     lifecycleManager.triggerDone();
+   *     return [result];           // or returnVoid()
+   *   } catch (Throwable t) {
+   *     lifecycleManager.triggerDone();
+   *     throw t;
+   *   }
+   * 
+ */ + private void generateDecoratedAgentMethod(ClassCreator cc, MethodInfo method, + FieldDescriptor delegateDesc, FieldDescriptor lcmDesc) { + + String agentName = extractAgentName(method); + String userMessage = extractAnnotationText(method, USER_MESSAGE_ANNOTATION); + String systemMessage = extractAnnotationText(method, SYSTEM_MESSAGE_ANNOTATION); + final boolean isVoid = method.returnType().kind() == Type.Kind.VOID; + + MethodCreator mc = cc.getMethodCreator(MethodDescriptor.of(method)); + mc.setModifiers(Modifier.PUBLIC); + for (Type exType : method.exceptions()) { + mc.addException(exType.name().toString()); + } + + // Store @Agent metadata on the current thread so that DaprChatModelDecorator can + // retrieve the real agent name and messages if the activation below fails and the + // decorator falls through to direct delegation (lazy-activation path). + mc.invokeStaticMethod( + MethodDescriptor.ofMethod(DaprAgentMetadataHolder.class, "set", + void.class, String.class, String.class, String.class), + mc.load(agentName), + userMessage != null ? mc.load(userMessage) : mc.loadNull(), + systemMessage != null ? mc.load(systemMessage) : mc.loadNull()); + + // Try to activate the Dapr agent lifecycle. This may fail when running on + // threads without a CDI request scope (e.g., LangChain4j's parallel executor). + // In that case, fall through to a direct delegate call without Dapr routing. + TryBlock activateTry = mc.tryBlock(); + ResultHandle lcm = activateTry.readInstanceField(lcmDesc, activateTry.getThis()); + activateTry.invokeVirtualMethod( + MethodDescriptor.ofMethod(AgentRunLifecycleManager.class, "getOrActivate", + String.class, String.class, String.class, String.class), + lcm, + activateTry.load(agentName), + userMessage != null + ? activateTry.load(userMessage) : activateTry.loadNull(), + systemMessage != null + ? activateTry.load(systemMessage) : activateTry.loadNull()); + + // If activation fails (no request scope), delegate directly without Dapr routing. + CatchBlockCreator activateCatch = activateTry.addCatch(Throwable.class); + { + ResultHandle delFallback = activateCatch.readInstanceField( + delegateDesc, activateCatch.getThis()); + ResultHandle[] fallbackParams = new ResultHandle[method.parametersCount()]; + for (int i = 0; i < fallbackParams.length; i++) { + fallbackParams[i] = activateCatch.getMethodParam(i); + } + if (isVoid) { + activateCatch.invokeInterfaceMethod( + MethodDescriptor.of(method), delFallback, fallbackParams); + activateCatch.returnVoid(); + } else { + ResultHandle fallbackResult = activateCatch.invokeInterfaceMethod( + MethodDescriptor.of(method), delFallback, fallbackParams); + activateCatch.returnValue(fallbackResult); + } + } + + // Activation succeeded -- wrap the delegate call with triggerDone() on both paths. + // try { ... } catch (Throwable t) { ... } + TryBlock tryBlock = mc.tryBlock(); + + ResultHandle del = tryBlock.readInstanceField(delegateDesc, tryBlock.getThis()); + ResultHandle[] params = new ResultHandle[method.parametersCount()]; + for (int i = 0; i < params.length; i++) { + params[i] = tryBlock.getMethodParam(i); + } + + // Normal path: delegate call + triggerDone + return + ResultHandle result = null; + if (!isVoid) { + result = tryBlock.invokeInterfaceMethod( + MethodDescriptor.of(method), del, params); + } else { + tryBlock.invokeInterfaceMethod(MethodDescriptor.of(method), del, params); + } + + ResultHandle lcmInTry = tryBlock.readInstanceField(lcmDesc, tryBlock.getThis()); + tryBlock.invokeVirtualMethod( + MethodDescriptor.ofMethod( + AgentRunLifecycleManager.class, "triggerDone", void.class), + lcmInTry); + tryBlock.invokeStaticMethod( + MethodDescriptor.ofMethod(DaprAgentMetadataHolder.class, "clear", void.class)); + + if (isVoid) { + tryBlock.returnVoid(); + } else { + tryBlock.returnValue(result); + } + + // Exception path: triggerDone + rethrow + CatchBlockCreator catchBlock = tryBlock.addCatch(Throwable.class); + ResultHandle lcmInCatch = + catchBlock.readInstanceField(lcmDesc, catchBlock.getThis()); + catchBlock.invokeVirtualMethod( + MethodDescriptor.ofMethod( + AgentRunLifecycleManager.class, "triggerDone", void.class), + lcmInCatch); + catchBlock.invokeStaticMethod( + MethodDescriptor.ofMethod(DaprAgentMetadataHolder.class, "clear", void.class)); + catchBlock.throwException(catchBlock.getCaughtException()); + } + + /** + * Generates a trivial delegation body for non-{@code @Agent} abstract interface methods. + *
+   *   return delegate.method(params);   // or just delegate.method(params); for void
+   * 
+ */ + private void generateDelegateMethod(ClassCreator cc, MethodInfo method, + FieldDescriptor delegateDesc) { + + final boolean isVoid = method.returnType().kind() == Type.Kind.VOID; + + MethodCreator mc = cc.getMethodCreator(MethodDescriptor.of(method)); + mc.setModifiers(Modifier.PUBLIC); + for (Type exType : method.exceptions()) { + mc.addException(exType.name().toString()); + } + + ResultHandle del = mc.readInstanceField(delegateDesc, mc.getThis()); + ResultHandle[] params = new ResultHandle[method.parametersCount()]; + for (int i = 0; i < params.length; i++) { + params[i] = mc.getMethodParam(i); + } + + if (isVoid) { + mc.invokeInterfaceMethod(MethodDescriptor.of(method), del, params); + mc.returnVoid(); + } else { + ResultHandle result = mc.invokeInterfaceMethod( + MethodDescriptor.of(method), del, params); + mc.returnValue(result); + } + } + + // ------------------------------------------------------------------------- + // 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(); + } + + // ------------------------------------------------------------------------- + // Annotation metadata extraction helpers + // ------------------------------------------------------------------------- + + 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; + } + + // ------------------------------------------------------------------------- + // Interceptor / annotation-transformer build steps (unchanged) + // ------------------------------------------------------------------------- + + /** + * Automatically apply {@code @DaprAgentToolInterceptorBinding} to every + * {@code @Tool}-annotated method in the application index. + */ + @BuildStep + @SuppressWarnings("deprecation") + AnnotationsTransformerBuildItem addDaprInterceptorToToolMethods() { + return new AnnotationsTransformerBuildItem( + AnnotationsTransformer.appliedToMethod() + .whenMethod(m -> m.hasAnnotation(TOOL_ANNOTATION)) + .thenTransform(t -> t.add(DAPR_TOOL_INTERCEPTOR_BINDING))); + } + + /** + * Automatically apply {@code @DaprAgentInterceptorBinding} to every + * {@code @Agent}-annotated method in the application index. + * + *

This causes {@link io.quarkiverse.dapr.langchain4j.agent.DaprAgentMethodInterceptor} + * to fire when an {@code @Agent} method is called on a regular CDI bean (not a synthetic + * AiService bean). For synthetic AiService beans the generated CDI decorator (produced by + * {@link #generateAgentDecorators}) is the authoritative hook point. + */ + @BuildStep + @SuppressWarnings("deprecation") + AnnotationsTransformerBuildItem addDaprInterceptorToAgentMethods() { + return new AnnotationsTransformerBuildItem( + AnnotationsTransformer.appliedToMethod() + .whenMethod(m -> m.hasAnnotation(AGENT_ANNOTATION)) + .thenTransform(t -> t.add(DAPR_AGENT_INTERCEPTOR_BINDING))); + } + + /** + * Also apply the interceptor binding at the class level for any CDI bean whose + * declared class itself has {@code @Tool} (less common but supported by LangChain4j). + */ + @BuildStep + @SuppressWarnings("deprecation") + AnnotationsTransformerBuildItem addDaprInterceptorToToolClasses() { + return new AnnotationsTransformerBuildItem( + AnnotationsTransformer.appliedToClass() + .whenClass(c -> { + for (MethodInfo method : c.methods()) { + if (method.hasAnnotation(TOOL_ANNOTATION)) { + return true; + } + } + return false; + }) + .thenTransform(t -> t.add(DAPR_TOOL_INTERCEPTOR_BINDING))); + } +} diff --git a/quarkus/examples/pom.xml b/quarkus/examples/pom.xml new file mode 100644 index 0000000000..2e8ac26391 --- /dev/null +++ b/quarkus/examples/pom.xml @@ -0,0 +1,95 @@ + + + 4.0.0 + + io.dapr.quarkus + dapr-quarkus-agentic-parent + 1.18.0-SNAPSHOT + ../pom.xml + + + quarkus-agentic-dapr-examples + Quarkus Agentic Dapr - Examples + + + + + io.dapr.quarkus + quarkus-agentic-dapr + ${project.version} + + + + io.dapr.quarkus + quarkus-agentic-dapr-agents-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 + + + + + + diff --git a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/CreativeWriter.java b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/CreativeWriter.java new file mode 100644 index 0000000000..ec67d9a470 --- /dev/null +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/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.quarkiverse.dapr.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/quarkiverse/dapr/examples/ParallelCreator.java b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelCreator.java new file mode 100644 index 0000000000..30277c3292 --- /dev/null +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/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.quarkiverse.dapr.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/quarkiverse/dapr/examples/ParallelResource.java b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelResource.java new file mode 100644 index 0000000000..52a44879b5 --- /dev/null +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/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.quarkiverse.dapr.examples; + +import jakarta.inject.Inject; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; + +/** + * REST endpoint that triggers the parallel creation workflow. + * + *

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

Example usage: + *

+ * curl "http://localhost:8080/parallel?topic=dragons&country=France&style=comedy"
+ * 
+ */ +@Path("/parallel") +public class ParallelResource { + + @Inject + ParallelCreator parallelCreator; + + @GET + @Produces(MediaType.APPLICATION_JSON) + public ParallelStatus create( + @QueryParam("topic") @DefaultValue("dragons and wizards") String topic, + @QueryParam("country") @DefaultValue("France") String country, + @QueryParam("style") @DefaultValue("fantasy") String style) { + return parallelCreator.create(topic, country, style); + } +} diff --git a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelStatus.java b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelStatus.java new file mode 100644 index 0000000000..f4737d3b34 --- /dev/null +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/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.quarkiverse.dapr.examples; + +public record ParallelStatus(String status, String story, String summary) { +} diff --git a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchResource.java b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchResource.java new file mode 100644 index 0000000000..db3e5e4e62 --- /dev/null +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchResource.java @@ -0,0 +1,55 @@ +/* + * 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.quarkiverse.dapr.examples; + +import jakarta.inject.Inject; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; + +/** + * REST endpoint that triggers a research workflow with tool calls routed through + * Dapr Workflow Activities. + * + *

Each request: + *

    + *
  1. Starts a {@code SequentialOrchestrationWorkflow} (orchestration level).
  2. + *
  3. For the {@link ResearchWriter} sub-agent, starts an {@code AgentRunWorkflow} + * (per-agent level).
  4. + *
  5. Each LLM tool call ({@code getPopulation} / {@code getCapital}) is executed + * inside a {@code ToolCallActivity} (tool-call level).
  6. + *
+ * + *

Example usage: + *

+ * curl "http://localhost:8080/research?country=France"
+ * curl "http://localhost:8080/research?country=Germany"
+ * 
+ */ +@Path("/research") +public class ResearchResource { + + @Inject + ResearchWriter researchWriter; + + @GET + @Produces(MediaType.TEXT_PLAIN) + public String research( + @QueryParam("country") @DefaultValue("France") String country) { + return researchWriter.research(country); + } +} diff --git a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchTools.java b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchTools.java new file mode 100644 index 0000000000..9af9e29187 --- /dev/null +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchTools.java @@ -0,0 +1,70 @@ +/* + * 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.quarkiverse.dapr.examples; + +import dev.langchain4j.agent.tool.Tool; +import jakarta.enterprise.context.ApplicationScoped; + +/** + * CDI bean providing research tools for the {@link ResearchWriter} agent. + * + *

Because the {@code quarkus-agentic-dapr} extension automatically applies + * {@code @DaprAgentToolInterceptorBinding} to all {@code @Tool}-annotated methods at + * build time, every call to these methods that occurs inside a Dapr-backed agent run is + * transparently routed through a {@code ToolCallActivity} 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/quarkiverse/dapr/examples/ResearchWriter.java b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchWriter.java new file mode 100644 index 0000000000..aa6f2c183d --- /dev/null +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchWriter.java @@ -0,0 +1,48 @@ +/* + * 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.quarkiverse.dapr.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 call is intercepted by the Dapr + * extension and executed inside a {@code ToolCallActivity} Dapr Workflow Activity — + * providing a durable, auditable record of every tool invocation. + * + *

Architecture note: No changes are required in this interface to enable the + * Dapr routing. The {@code quarkus-agentic-dapr} deployment module applies + * {@code @DaprAgentToolInterceptorBinding} to all {@code @Tool}-annotated methods at + * build time, and {@code DaprWorkflowPlanner} sets the per-agent context on the + * executing thread before the agent starts. + */ +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/quarkiverse/dapr/examples/StoryCreator.java b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StoryCreator.java new file mode 100644 index 0000000000..26b6dd41eb --- /dev/null +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/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.quarkiverse.dapr.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. + * + *

Uses {@code @SequenceAgent} which discovers the {@code DaprWorkflowAgentsBuilder} + * via Java SPI to create the Dapr Workflow-based sequential orchestration. + */ +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/quarkiverse/dapr/examples/StoryResource.java b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StoryResource.java new file mode 100644 index 0000000000..afabf11f56 --- /dev/null +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/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.quarkiverse.dapr.examples; + +import jakarta.inject.Inject; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; + +/** + * REST endpoint that triggers the sequential story creation workflow. + * + *

Example usage: + *

+ * curl "http://localhost:8080/story?topic=dragons&style=comedy"
+ * 
+ */ +@Path("/story") +public class StoryResource { + + @Inject + StoryCreator storyCreator; + + @GET + @Produces(MediaType.TEXT_PLAIN) + public String createStory( + @QueryParam("topic") @DefaultValue("dragons and wizards") String topic, + @QueryParam("style") @DefaultValue("fantasy") String style) { + return storyCreator.write(topic, style); + } +} diff --git a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StyleEditor.java b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StyleEditor.java new file mode 100644 index 0000000000..871c88d039 --- /dev/null +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/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.quarkiverse.dapr.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/resources/application.properties b/quarkus/examples/src/main/resources/application.properties new file mode 100644 index 0000000000..a1c050062d --- /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.quarkiverse.dapr.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/quarkiverse/dapr/examples/DaprWorkflowClientTest.java b/quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/DaprWorkflowClientTest.java new file mode 100644 index 0000000000..2281527106 --- /dev/null +++ b/quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/DaprWorkflowClientTest.java @@ -0,0 +1,28 @@ +package io.quarkiverse.dapr.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/quarkiverse/dapr/examples/DockerAvailableCondition.java b/quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/DockerAvailableCondition.java new file mode 100644 index 0000000000..0747891d12 --- /dev/null +++ b/quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/DockerAvailableCondition.java @@ -0,0 +1,48 @@ +package io.quarkiverse.dapr.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/quarkiverse/dapr/examples/MockChatModel.java b/quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/MockChatModel.java new file mode 100644 index 0000000000..2a0d4a2321 --- /dev/null +++ b/quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/MockChatModel.java @@ -0,0 +1,32 @@ +package io.quarkiverse.dapr.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. + * Takes priority over the OpenAI ChatModel bean via {@code @Alternative @Priority(1)}. + */ +@Alternative +@Priority(1) +@ApplicationScoped +public class MockChatModel implements ChatModel { + + @Override + public ChatResponse doChat(ChatRequest request) { + return ChatResponse.builder() + .aiMessage(AiMessage.from("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/quarkiverse/dapr/examples/ParallelResourceTest.java b/quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/ParallelResourceTest.java new file mode 100644 index 0000000000..03ce2263e3 --- /dev/null +++ b/quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/ParallelResourceTest.java @@ -0,0 +1,51 @@ +package io.quarkiverse.dapr.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/quarkiverse/dapr/examples/StoryResourceTest.java b/quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/StoryResourceTest.java new file mode 100644 index 0000000000..d0d110bcea --- /dev/null +++ b/quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/StoryResourceTest.java @@ -0,0 +1,59 @@ +package io.quarkiverse.dapr.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/resources/application.properties b/quarkus/examples/src/test/resources/application.properties new file mode 100644 index 0000000000..e437b94593 --- /dev/null +++ b/quarkus/examples/src/test/resources/application.properties @@ -0,0 +1,8 @@ +# 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 + +# 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..c15a341e59 --- /dev/null +++ b/quarkus/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + + io.dapr + dapr-sdk-parent + 1.18.0-SNAPSHOT + ../pom.xml + + io.dapr.quarkus + dapr-quarkus-agentic-parent + + pom + Dapr Quarkus Agentic - Parent + + + runtime + deployment + quarkus-agentic-dapr-agents-registry + examples + + + + 17 + 17 + UTF-8 + 3.31.2 + 2.5.0 + 1.7.1 + + + + + + io.quarkus + quarkus-bom + ${quarkus.version} + pom + import + + + + commons-io + commons-io + 2.20.0 + + + + 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-agentic-dapr-agents-registry/pom.xml b/quarkus/quarkus-agentic-dapr-agents-registry/pom.xml new file mode 100644 index 0000000000..37f51de9b3 --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/pom.xml @@ -0,0 +1,92 @@ + + + 4.0.0 + + io.dapr.quarkus + dapr-quarkus-agentic-parent + 1.18.0-SNAPSHOT + ../pom.xml + + + quarkus-agentic-dapr-agents-registry + Quarkus Agentic Dapr - Agent Registry + + + + io.quarkiverse.dapr + quarkus-dapr + ${quarkus-dapr.version} + + + com.fasterxml.jackson.core + jackson-databind + + + io.quarkiverse.langchain4j + quarkus-langchain4j-agentic + ${quarkus-langchain4j.version} + + + io.quarkus + quarkus-arc + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + 3.26.3 + test + + + org.mockito + mockito-core + 5.14.2 + test + + + io.quarkus + quarkus-junit + test + + + io.quarkiverse.langchain4j + quarkus-langchain4j-openai + ${quarkus-langchain4j.version} + test + + + org.awaitility + awaitility + test + + + + + + + maven-compiler-plugin + 3.13.0 + + + -parameters + + + + + maven-surefire-plugin + 3.5.2 + + + org.jboss.logmanager.LogManager + + + + + + diff --git a/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/AgentMetadata.java b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/AgentMetadata.java new file mode 100644 index 0000000000..3d4a5ca0b5 --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/AgentMetadata.java @@ -0,0 +1,172 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.quarkiverse.dapr.agents.registry.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public class AgentMetadata { + + @JsonProperty("appid") + private String appId; + + @JsonProperty("type") + private String type; + + @JsonProperty("orchestrator") + private boolean orchestrator; + + @JsonProperty("role") + private String role = ""; + + @JsonProperty("goal") + private String goal = ""; + + @JsonProperty("instructions") + private List instructions; + + @JsonProperty("statestore") + private String statestore; + + @JsonProperty("system_prompt") + private String systemPrompt; + + @JsonProperty("framework") + private String framework; + + public AgentMetadata() { + } + + private AgentMetadata(Builder builder) { + this.appId = builder.appId; + this.type = builder.type; + this.orchestrator = builder.orchestrator; + this.role = builder.role; + this.goal = builder.goal; + this.instructions = builder.instructions; + this.statestore = builder.statestore; + this.systemPrompt = builder.systemPrompt; + this.framework = builder.framework; + } + + public static Builder builder() { + return new Builder(); + } + + public String getAppId() { + return appId; + } + + public String getType() { + return type; + } + + public boolean isOrchestrator() { + return orchestrator; + } + + public String getRole() { + return role; + } + + public String getGoal() { + return goal; + } + + public List getInstructions() { + return instructions; + } + + public String getStatestore() { + return statestore; + } + + public String getSystemPrompt() { + return systemPrompt; + } + + public String getFramework() { + return framework; + } + + public static class Builder { + private String appId; + private String type; + private boolean orchestrator = false; + private String role = ""; + private String goal = ""; + private List instructions; + private String statestore; + private String systemPrompt; + private String framework; + + public Builder appId(String appId) { + this.appId = appId; + return this; + } + + public Builder type(String type) { + this.type = type; + return this; + } + + public Builder orchestrator(boolean orchestrator) { + this.orchestrator = orchestrator; + return this; + } + + public Builder role(String role) { + this.role = role; + return this; + } + + public Builder goal(String goal) { + this.goal = goal; + return this; + } + + public Builder instructions(List instructions) { + this.instructions = instructions; + return this; + } + + public Builder statestore(String statestore) { + this.statestore = statestore; + return this; + } + + public Builder systemPrompt(String systemPrompt) { + this.systemPrompt = systemPrompt; + return this; + } + + public Builder framework(String framework) { + this.framework = framework; + return this; + } + + /** + * Builds the AgentMetadata, validating required fields. + * + * @return the constructed AgentMetadata + */ + public AgentMetadata build() { + if (appId == null || type == null) { + throw new IllegalStateException("appId and type are required"); + } + return new AgentMetadata(this); + } + } +} diff --git a/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/AgentMetadataSchema.java b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/AgentMetadataSchema.java new file mode 100644 index 0000000000..e5572b6e31 --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/AgentMetadataSchema.java @@ -0,0 +1,232 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.quarkiverse.dapr.agents.registry.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class AgentMetadataSchema { + + @JsonProperty("schema_version") + private String schemaVersion; + + @JsonProperty("agent") + private AgentMetadata agent; + + @JsonProperty("name") + private String name; + + @JsonProperty("registered_at") + private String registeredAt; + + @JsonProperty("pubsub") + private PubSubMetadata pubsub; + + @JsonProperty("memory") + private MemoryMetadata memory; + + @JsonProperty("llm") + private LlmMetadata llm; + + @JsonProperty("registry") + private RegistryMetadata registry; + + @JsonProperty("tools") + private List tools; + + @JsonProperty("max_iterations") + private Integer maxIterations; + + @JsonProperty("tool_choice") + private String toolChoice; + + @JsonProperty("agent_metadata") + private Map agentMetadata; + + public AgentMetadataSchema() { + } + + private AgentMetadataSchema(Builder builder) { + this.schemaVersion = builder.schemaVersion; + this.agent = builder.agent; + this.name = builder.name; + this.registeredAt = builder.registeredAt; + this.pubsub = builder.pubsub; + this.memory = builder.memory; + this.llm = builder.llm; + this.registry = builder.registry; + this.tools = builder.tools; + this.maxIterations = builder.maxIterations; + this.toolChoice = builder.toolChoice; + this.agentMetadata = builder.agentMetadata; + } + + public static Builder builder() { + return new Builder(); + } + + public String getSchemaVersion() { + return schemaVersion; + } + + public AgentMetadata getAgent() { + return agent; + } + + public String getName() { + return name; + } + + public String getRegisteredAt() { + return registeredAt; + } + + public PubSubMetadata getPubsub() { + return pubsub; + } + + public MemoryMetadata getMemory() { + return memory; + } + + public LlmMetadata getLlm() { + return llm; + } + + public RegistryMetadata getRegistry() { + return registry; + } + + public List getTools() { + return tools; + } + + public Integer getMaxIterations() { + return maxIterations; + } + + public String getToolChoice() { + return toolChoice; + } + + public Map getAgentMetadata() { + return agentMetadata; + } + + public static class Builder { + private String schemaVersion; + private AgentMetadata agent; + private String name; + private String registeredAt; + private PubSubMetadata pubsub; + private MemoryMetadata memory; + private LlmMetadata llm; + private RegistryMetadata registry; + private List tools; + private Integer maxIterations; + private String toolChoice; + private Map agentMetadata; + + public Builder schemaVersion(String schemaVersion) { + this.schemaVersion = schemaVersion; + return this; + } + + public Builder agent(AgentMetadata agent) { + this.agent = agent; + return this; + } + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder registeredAt(String registeredAt) { + this.registeredAt = registeredAt; + return this; + } + + public Builder pubsub(PubSubMetadata pubsub) { + this.pubsub = pubsub; + return this; + } + + public Builder memory(MemoryMetadata memory) { + this.memory = memory; + return this; + } + + public Builder llm(LlmMetadata llm) { + this.llm = llm; + return this; + } + + public Builder registry(RegistryMetadata registry) { + this.registry = registry; + return this; + } + + public Builder tools(List tools) { + this.tools = tools; + return this; + } + + /** + * Adds a single tool to the tools list, initializing the list if needed. + * + * @param tool the tool metadata to add + * @return this builder + */ + public Builder addTool(ToolMetadata tool) { + if (this.tools == null) { + this.tools = new ArrayList<>(); + } + this.tools.add(tool); + return this; + } + + public Builder maxIterations(Integer maxIterations) { + this.maxIterations = maxIterations; + return this; + } + + public Builder toolChoice(String toolChoice) { + this.toolChoice = toolChoice; + return this; + } + + public Builder agentMetadata(Map agentMetadata) { + this.agentMetadata = agentMetadata; + return this; + } + + /** + * Builds the AgentMetadataSchema, validating required fields. + * + * @return the constructed AgentMetadataSchema + */ + public AgentMetadataSchema build() { + if (schemaVersion == null || agent == null || name == null || registeredAt == null) { + throw new IllegalStateException("schemaVersion, agent, name, and registeredAt are required"); + } + return new AgentMetadataSchema(this); + } + } +} diff --git a/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/LlmMetadata.java b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/LlmMetadata.java new file mode 100644 index 0000000000..d2cfdbb345 --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/LlmMetadata.java @@ -0,0 +1,170 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.quarkiverse.dapr.agents.registry.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class LlmMetadata { + + @JsonProperty("client") + private String client; + + @JsonProperty("provider") + private String provider; + + @JsonProperty("api") + private String api = "unknown"; + + @JsonProperty("model") + private String model = "unknown"; + + @JsonProperty("component_name") + private String componentName; + + @JsonProperty("base_url") + private String baseUrl; + + @JsonProperty("azure_endpoint") + private String azureEndpoint; + + @JsonProperty("azure_deployment") + private String azureDeployment; + + @JsonProperty("prompt_template") + private String promptTemplate; + + public LlmMetadata() { + } + + private LlmMetadata(Builder builder) { + this.client = builder.client; + this.provider = builder.provider; + this.api = builder.api; + this.model = builder.model; + this.componentName = builder.componentName; + this.baseUrl = builder.baseUrl; + this.azureEndpoint = builder.azureEndpoint; + this.azureDeployment = builder.azureDeployment; + this.promptTemplate = builder.promptTemplate; + } + + public static Builder builder() { + return new Builder(); + } + + public String getClient() { + return client; + } + + public String getProvider() { + return provider; + } + + public String getApi() { + return api; + } + + public String getModel() { + return model; + } + + public String getComponentName() { + return componentName; + } + + public String getBaseUrl() { + return baseUrl; + } + + public String getAzureEndpoint() { + return azureEndpoint; + } + + public String getAzureDeployment() { + return azureDeployment; + } + + public String getPromptTemplate() { + return promptTemplate; + } + + public static class Builder { + private String client; + private String provider; + private String api = "unknown"; + private String model = "unknown"; + private String componentName; + private String baseUrl; + private String azureEndpoint; + private String azureDeployment; + private String promptTemplate; + + public Builder client(String client) { + this.client = client; + return this; + } + + public Builder provider(String provider) { + this.provider = provider; + return this; + } + + public Builder api(String api) { + this.api = api; + return this; + } + + public Builder model(String model) { + this.model = model; + return this; + } + + public Builder componentName(String componentName) { + this.componentName = componentName; + return this; + } + + public Builder baseUrl(String baseUrl) { + this.baseUrl = baseUrl; + return this; + } + + public Builder azureEndpoint(String azureEndpoint) { + this.azureEndpoint = azureEndpoint; + return this; + } + + public Builder azureDeployment(String azureDeployment) { + this.azureDeployment = azureDeployment; + return this; + } + + public Builder promptTemplate(String promptTemplate) { + this.promptTemplate = promptTemplate; + return this; + } + + /** + * Builds the LlmMetadata, validating required fields. + * + * @return the constructed LlmMetadata + */ + public LlmMetadata build() { + if (client == null || provider == null) { + throw new IllegalStateException("client and provider are required"); + } + return new LlmMetadata(this); + } + } +} diff --git a/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/MemoryMetadata.java b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/MemoryMetadata.java new file mode 100644 index 0000000000..57cc110314 --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/MemoryMetadata.java @@ -0,0 +1,72 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.quarkiverse.dapr.agents.registry.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class MemoryMetadata { + + @JsonProperty("type") + private String type; + + @JsonProperty("statestore") + private String statestore; + + public MemoryMetadata() { + } + + private MemoryMetadata(Builder builder) { + this.type = builder.type; + this.statestore = builder.statestore; + } + + public static Builder builder() { + return new Builder(); + } + + public String getType() { + return type; + } + + public String getStatestore() { + return statestore; + } + + public static class Builder { + private String type; + private String statestore; + + public Builder type(String type) { + this.type = type; + return this; + } + + public Builder statestore(String statestore) { + this.statestore = statestore; + return this; + } + + /** + * Builds a new MemoryMetadata instance. + * + * @return the built MemoryMetadata + */ + public MemoryMetadata build() { + if (type == null) { + throw new IllegalStateException("type is required"); + } + return new MemoryMetadata(this); + } + } +} diff --git a/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/PubSubMetadata.java b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/PubSubMetadata.java new file mode 100644 index 0000000000..1a844c18be --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/PubSubMetadata.java @@ -0,0 +1,86 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.quarkiverse.dapr.agents.registry.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class PubSubMetadata { + + @JsonProperty("name") + private String name; + + @JsonProperty("broadcast_topic") + private String broadcastTopic; + + @JsonProperty("agent_topic") + private String agentTopic; + + public PubSubMetadata() { + } + + private PubSubMetadata(Builder builder) { + this.name = builder.name; + this.broadcastTopic = builder.broadcastTopic; + this.agentTopic = builder.agentTopic; + } + + public static Builder builder() { + return new Builder(); + } + + public String getName() { + return name; + } + + public String getBroadcastTopic() { + return broadcastTopic; + } + + public String getAgentTopic() { + return agentTopic; + } + + public static class Builder { + private String name; + private String broadcastTopic; + private String agentTopic; + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder broadcastTopic(String broadcastTopic) { + this.broadcastTopic = broadcastTopic; + return this; + } + + public Builder agentTopic(String agentTopic) { + this.agentTopic = agentTopic; + return this; + } + + /** + * Builds a new PubSubMetadata instance. + * + * @return the built PubSubMetadata + */ + public PubSubMetadata build() { + if (name == null) { + throw new IllegalStateException("name is required"); + } + return new PubSubMetadata(this); + } + } +} diff --git a/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/RegistryMetadata.java b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/RegistryMetadata.java new file mode 100644 index 0000000000..3ca17bc54f --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/RegistryMetadata.java @@ -0,0 +1,64 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.quarkiverse.dapr.agents.registry.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class RegistryMetadata { + + @JsonProperty("statestore") + private String statestore; + + @JsonProperty("name") + private String name; + + public RegistryMetadata() { + } + + private RegistryMetadata(Builder builder) { + this.statestore = builder.statestore; + this.name = builder.name; + } + + public static Builder builder() { + return new Builder(); + } + + public String getStatestore() { + return statestore; + } + + public String getName() { + return name; + } + + public static class Builder { + private String statestore; + private String name; + + public Builder statestore(String statestore) { + this.statestore = statestore; + return this; + } + + public Builder name(String name) { + this.name = name; + return this; + } + + public RegistryMetadata build() { + return new RegistryMetadata(this); + } + } +} diff --git a/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/ToolMetadata.java b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/ToolMetadata.java new file mode 100644 index 0000000000..cca5a0a68c --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/ToolMetadata.java @@ -0,0 +1,86 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.quarkiverse.dapr.agents.registry.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class ToolMetadata { + + @JsonProperty("tool_name") + private String toolName; + + @JsonProperty("tool_description") + private String toolDescription; + + @JsonProperty("tool_args") + private String toolArgs; + + public ToolMetadata() { + } + + private ToolMetadata(Builder builder) { + this.toolName = builder.toolName; + this.toolDescription = builder.toolDescription; + this.toolArgs = builder.toolArgs; + } + + public static Builder builder() { + return new Builder(); + } + + public String getToolName() { + return toolName; + } + + public String getToolDescription() { + return toolDescription; + } + + public String getToolArgs() { + return toolArgs; + } + + public static class Builder { + private String toolName; + private String toolDescription; + private String toolArgs; + + public Builder toolName(String toolName) { + this.toolName = toolName; + return this; + } + + public Builder toolDescription(String toolDescription) { + this.toolDescription = toolDescription; + return this; + } + + public Builder toolArgs(String toolArgs) { + this.toolArgs = toolArgs; + return this; + } + + /** + * Builds the ToolMetadata, validating required fields. + * + * @return the constructed ToolMetadata + */ + public ToolMetadata build() { + if (toolName == null || toolDescription == null || toolArgs == null) { + throw new IllegalStateException("toolName, toolDescription, and toolArgs are required"); + } + return new ToolMetadata(this); + } + } +} diff --git a/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/service/AgentRegistry.java b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/service/AgentRegistry.java new file mode 100644 index 0000000000..0be11bb9cc --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/service/AgentRegistry.java @@ -0,0 +1,267 @@ +/* + * 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.quarkiverse.dapr.agents.registry.service; + +import io.dapr.client.DaprClient; +import io.quarkiverse.dapr.agents.registry.model.AgentMetadata; +import io.quarkiverse.dapr.agents.registry.model.AgentMetadataSchema; +import io.quarkus.runtime.StartupEvent; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.Any; +import jakarta.enterprise.inject.spi.Bean; +import jakarta.enterprise.inject.spi.BeanManager; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@ApplicationScoped +public class AgentRegistry { + + private static final Logger LOG = Logger.getLogger(AgentRegistry.class); + + /** + * Fully-qualified name of langchain4j {@code @Agent} annotation. + */ + private static final String AGENT_ANNOTATION_NAME = "dev.langchain4j.agentic.Agent"; + + /** + * Fully-qualified name of langchain4j {@code @SystemMessage} annotation. + */ + private static final String SYSTEM_MESSAGE_ANNOTATION_NAME = "dev.langchain4j.service.SystemMessage"; + + @Inject + DaprClient client; + + @Inject + BeanManager beanManager; + + @ConfigProperty(name = "dapr.agents.statestore", defaultValue = "kvstore") + String statestore; + + @ConfigProperty(name = "dapr.appid", defaultValue = "local-dapr-app") + String appId; + + @ConfigProperty(name = "dapr.agents.team", defaultValue = "default") + String team; + + void onStartup(@Observes StartupEvent event) { + discoverAndRegisterAgents(); + } + + void discoverAndRegisterAgents() { + LOG.info("Starting agent auto-discovery..."); + Set> beans = beanManager.getBeans(Object.class, Any.Literal.INSTANCE); + LOG.debugf("Found %d CDI beans to scan", beans.size()); + + Set scannedInterfaces = new HashSet<>(); + // Collect all interface classes from CDI beans first + List> interfacesToScan = new ArrayList<>(); + + for (Bean bean : beans) { + for (Type type : bean.getTypes()) { + if (type instanceof Class clazz && clazz.isInterface()) { + if (scannedInterfaces.add(clazz.getName())) { + interfacesToScan.add(clazz); + } + } + } + } + + // Also discover sub-agent classes referenced by composite agent annotations + // (e.g., @SequenceAgent(subAgents = {CreativeWriter.class, StyleEditor.class})). + // Sub-agents are often not CDI beans themselves, so they won't appear in BeanManager. + List> subAgentClasses = new ArrayList<>(); + for (Class iface : interfacesToScan) { + for (Method method : iface.getDeclaredMethods()) { + for (Annotation ann : method.getDeclaredAnnotations()) { + for (Class subAgent : extractSubAgentClasses(ann)) { + if (subAgent.isInterface() && scannedInterfaces.add(subAgent.getName())) { + subAgentClasses.add(subAgent); + LOG.debugf("Discovered sub-agent interface %s from %s on %s", + subAgent.getName(), ann.annotationType().getSimpleName(), iface.getName()); + } + } + } + } + } + interfacesToScan.addAll(subAgentClasses); + + // Scan all collected interfaces for @Agent methods + int registered = 0; + int failed = 0; + for (Class iface : interfacesToScan) { + LOG.debugf("Scanning interface: %s", iface.getName()); + List agents = scanForAgents(iface, appId); + if (!agents.isEmpty()) { + LOG.debugf("Found %d @Agent method(s) on interface %s", agents.size(), iface.getName()); + } + for (AgentMetadataSchema schema : agents) { + try { + registerAgent(schema); + registered++; + } catch (Exception e) { + failed++; + LOG.errorf(e, "Failed to register agent '%s' in state store '%s': %s", + schema.getName(), statestore, e.getMessage()); + } + } + } + + LOG.debugf("Scanned %d unique interfaces", scannedInterfaces.size()); + + if (registered == 0 && failed == 0) { + LOG.warn("No @Agent-annotated methods found on any CDI bean interface. " + + "Ensure your @Agent interfaces are registered as CDI beans."); + } else if (failed > 0) { + LOG.warnf("Agent discovery complete: %d registered, %d failed", registered, failed); + } else { + LOG.infof("Agent discovery complete: %d agent(s) registered successfully", registered); + } + } + + /** + * Extracts sub-agent classes from a composite agent annotation. + * + *

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

Uses name-based annotation matching ({@code annotationType().getName()}) instead of + * class identity ({@code method.getAnnotation(Agent.class)}) to handle classloader + * differences between library JARs and the Quarkus application classloader. + */ + static List scanForAgents(Class type, String appId) { + List result = new ArrayList<>(); + for (Method method : type.getDeclaredMethods()) { + Annotation agentAnn = findAnnotationByName(method, AGENT_ANNOTATION_NAME); + if (agentAnn == null) { + continue; + } + + String name = invokeStringMethod(agentAnn, "name"); + if (name == null || name.isBlank()) { + name = type.getSimpleName() + "." + method.getName(); + } + + String goal = invokeStringMethod(agentAnn, "description"); + + String systemPrompt = null; + Annotation smAnn = findAnnotationByName(method, SYSTEM_MESSAGE_ANNOTATION_NAME); + if (smAnn != null) { + String[] values = invokeMethod(smAnn, "value", String[].class); + String delimiter = invokeStringMethod(smAnn, "delimiter"); + if (values != null && values.length > 0) { + String joined = String.join(delimiter != null ? delimiter : "\n", values); + if (!joined.isBlank()) { + systemPrompt = joined; + } + } + } + + AgentMetadataSchema schema = AgentMetadataSchema.builder() + .schemaVersion("0.11.1") + .name(name) + .registeredAt(Instant.now().toString()) + .agent(AgentMetadata.builder() + .appId(appId) + .type("standalone") + .goal(goal) + .systemPrompt(systemPrompt) + .framework("langchain4j") + .build()) + .build(); + + result.add(schema); + } + return result; + } + + /** + * Finds an annotation on a method by its fully-qualified type name. + * This is resilient against classloader mismatches where the same annotation class + * may be loaded by different classloaders. + */ + private static Annotation findAnnotationByName(Method method, String annotationName) { + for (Annotation ann : method.getDeclaredAnnotations()) { + if (ann.annotationType().getName().equals(annotationName)) { + return ann; + } + } + return null; + } + + /** + * Invokes a no-arg method on an annotation proxy and returns the result as a String. + */ + private static String invokeStringMethod(Annotation ann, String methodName) { + return invokeMethod(ann, methodName, String.class); + } + + /** + * Invokes a no-arg method on an annotation proxy, casting to the expected type. + */ + @SuppressWarnings("unchecked") + private static T invokeMethod(Annotation ann, String methodName, Class returnType) { + try { + Object result = ann.annotationType().getMethod(methodName).invoke(ann); + return returnType.isInstance(result) ? (T) result : null; + } catch (Exception e) { + LOG.debugf("Failed to invoke %s.%s(): %s", + ann.annotationType().getSimpleName(), methodName, e.getMessage()); + return null; + } + } + + /** + * Registers an agent schema in the Dapr state store. + * + * @param schema the agent metadata schema to register + */ + public void registerAgent(AgentMetadataSchema schema) { + String key = "agents:" + team + ":" + schema.getName(); + LOG.infof("Registering agent: %s", key); + client.saveState(statestore, key, null, schema, + Map.of("contentType", "application/json"), null).block(); + } +} diff --git a/quarkus/quarkus-agentic-dapr-agents-registry/src/main/resources/META-INF/beans.xml b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000000..162171f0fb --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/resources/META-INF/beans.xml @@ -0,0 +1,6 @@ + + + diff --git a/quarkus/quarkus-agentic-dapr-agents-registry/src/main/resources/schema.json b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/resources/schema.json new file mode 100644 index 0000000000..5e926ac93a --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/resources/schema.json @@ -0,0 +1,461 @@ +{ + "$defs": { + "AgentMetadata": { + "description": "Metadata about an agent's configuration and capabilities.", + "properties": { + "appid": { + "description": "Dapr application ID of the agent", + "title": "Appid", + "type": "string" + }, + "type": { + "description": "Type of the agent (e.g., standalone, durable)", + "title": "Type", + "type": "string" + }, + "orchestrator": { + "default": false, + "description": "Indicates if the agent is an orchestrator", + "title": "Orchestrator", + "type": "boolean" + }, + "role": { + "default": "", + "description": "Role of the agent", + "title": "Role", + "type": "string" + }, + "goal": { + "default": "", + "description": "High-level objective of the agent", + "title": "Goal", + "type": "string" + }, + "instructions": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Instructions for the agent", + "title": "Instructions" + }, + "statestore": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dapr state store component name used by the agent", + "title": "Statestore" + }, + "system_prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "System prompt guiding the agent's behavior", + "title": "System Prompt" + }, + "framework": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Framework or library the agent is built with", + "title": "Framework" + } + }, + "required": [ + "appid", + "type" + ], + "title": "AgentMetadata", + "type": "object" + }, + "LLMMetadata": { + "description": "LLM configuration information.", + "properties": { + "client": { + "description": "LLM client used by the agent", + "title": "Client", + "type": "string" + }, + "provider": { + "description": "LLM provider used by the agent", + "title": "Provider", + "type": "string" + }, + "api": { + "default": "unknown", + "description": "API type used by the LLM client", + "title": "Api", + "type": "string" + }, + "model": { + "default": "unknown", + "description": "Model name or identifier", + "title": "Model", + "type": "string" + }, + "component_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dapr component name for the LLM client", + "title": "Component Name" + }, + "base_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Base URL for the LLM API if applicable", + "title": "Base Url" + }, + "azure_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Azure endpoint if using Azure OpenAI", + "title": "Azure Endpoint" + }, + "azure_deployment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Azure deployment name if using Azure OpenAI", + "title": "Azure Deployment" + }, + "prompt_template": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Prompt template used by the agent", + "title": "Prompt Template" + } + }, + "required": [ + "client", + "provider" + ], + "title": "LLMMetadata", + "type": "object" + }, + "MemoryMetadata": { + "description": "Memory configuration information.", + "properties": { + "type": { + "description": "Type of memory used by the agent", + "title": "Type", + "type": "string" + }, + "statestore": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dapr state store component name for memory", + "title": "Statestore" + } + }, + "required": [ + "type" + ], + "title": "MemoryMetadata", + "type": "object" + }, + "PubSubMetadata": { + "description": "Pub/Sub configuration information.", + "properties": { + "name": { + "description": "Pub/Sub component name", + "title": "Name", + "type": "string" + }, + "broadcast_topic": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Pub/Sub topic for broadcasting messages", + "title": "Broadcast Topic" + }, + "agent_topic": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Pub/Sub topic for direct agent messages", + "title": "Agent Topic" + } + }, + "required": [ + "name" + ], + "title": "PubSubMetadata", + "type": "object" + }, + "RegistryMetadata": { + "description": "Registry configuration information.", + "properties": { + "statestore": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of the statestore component for the registry", + "title": "Statestore" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of the team registry", + "title": "Name" + } + }, + "title": "RegistryMetadata", + "type": "object" + }, + "ToolMetadata": { + "description": "Metadata about a tool available to the agent.", + "properties": { + "tool_name": { + "description": "Name of the tool", + "title": "Tool Name", + "type": "string" + }, + "tool_description": { + "description": "Description of the tool's functionality", + "title": "Tool Description", + "type": "string" + }, + "tool_args": { + "description": "Arguments for the tool", + "title": "Tool Args", + "type": "string" + } + }, + "required": [ + "tool_name", + "tool_description", + "tool_args" + ], + "title": "ToolMetadata", + "type": "object" + } + }, + "description": "Schema for agent metadata including schema version.", + "properties": { + "schema_version": { + "description": "Version of the schema used for the agent metadata.", + "title": "Schema Version", + "type": "string" + }, + "agent": { + "$ref": "#/$defs/AgentMetadata", + "description": "Agent configuration and capabilities" + }, + "name": { + "description": "Name of the agent", + "title": "Name", + "type": "string" + }, + "registered_at": { + "description": "ISO 8601 timestamp of registration", + "title": "Registered At", + "type": "string" + }, + "pubsub": { + "anyOf": [ + { + "$ref": "#/$defs/PubSubMetadata" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Pub/sub configuration if enabled" + }, + "memory": { + "anyOf": [ + { + "$ref": "#/$defs/MemoryMetadata" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Memory configuration if enabled" + }, + "llm": { + "anyOf": [ + { + "$ref": "#/$defs/LLMMetadata" + }, + { + "type": "null" + } + ], + "default": null, + "description": "LLM configuration" + }, + "registry": { + "anyOf": [ + { + "$ref": "#/$defs/RegistryMetadata" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Registry configuration" + }, + "tools": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/ToolMetadata" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Available tools", + "title": "Tools" + }, + "max_iterations": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Maximum iterations for agent execution", + "title": "Max Iterations" + }, + "tool_choice": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Tool choice strategy", + "title": "Tool Choice" + }, + "agent_metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Additional metadata about the agent", + "title": "Agent Metadata" + } + }, + "required": [ + "schema_version", + "agent", + "name", + "registered_at" + ], + "title": "AgentMetadataSchema", + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "version": "0.11.1" +} \ No newline at end of file diff --git a/quarkus/quarkus-agentic-dapr-agents-registry/src/test/java/io/quarkiverse/dapr/agents/registry/model/AgentMetadataSchemaTest.java b/quarkus/quarkus-agentic-dapr-agents-registry/src/test/java/io/quarkiverse/dapr/agents/registry/model/AgentMetadataSchemaTest.java new file mode 100644 index 0000000000..6c58349a1e --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/test/java/io/quarkiverse/dapr/agents/registry/model/AgentMetadataSchemaTest.java @@ -0,0 +1,404 @@ +package io.quarkiverse.dapr.agents.registry.model; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AgentMetadataSchemaTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void buildMinimalSchema() { + AgentMetadataSchema schema = AgentMetadataSchema.builder() + .schemaVersion("0.11.1") + .name("test-agent") + .registeredAt("2025-01-01T00:00:00Z") + .agent(AgentMetadata.builder() + .appId("my-app") + .type("standalone") + .build()) + .build(); + + assertThat(schema.getSchemaVersion()).isEqualTo("0.11.1"); + assertThat(schema.getName()).isEqualTo("test-agent"); + assertThat(schema.getRegisteredAt()).isEqualTo("2025-01-01T00:00:00Z"); + assertThat(schema.getAgent().getAppId()).isEqualTo("my-app"); + assertThat(schema.getAgent().getType()).isEqualTo("standalone"); + assertThat(schema.getAgent().isOrchestrator()).isFalse(); + assertThat(schema.getAgent().getRole()).isEmpty(); + assertThat(schema.getAgent().getGoal()).isEmpty(); + assertThat(schema.getPubsub()).isNull(); + assertThat(schema.getMemory()).isNull(); + assertThat(schema.getLlm()).isNull(); + assertThat(schema.getRegistry()).isNull(); + assertThat(schema.getTools()).isNull(); + assertThat(schema.getMaxIterations()).isNull(); + assertThat(schema.getToolChoice()).isNull(); + assertThat(schema.getAgentMetadata()).isNull(); + } + + @Test + void buildFullSchema() { + AgentMetadataSchema schema = AgentMetadataSchema.builder() + .schemaVersion("0.11.1") + .name("orchestrator-agent") + .registeredAt("2025-06-15T10:30:00Z") + .agent(AgentMetadata.builder() + .appId("orch-app") + .type("durable") + .orchestrator(true) + .role("coordinator") + .goal("Coordinate tasks across agents") + .instructions(List.of("Be concise", "Delegate work")) + .statestore("statestore") + .systemPrompt("You are a coordinator agent.") + .framework("langchain4j") + .build()) + .pubsub(PubSubMetadata.builder() + .name("pubsub") + .broadcastTopic("broadcast") + .agentTopic("agent-messages") + .build()) + .memory(MemoryMetadata.builder() + .type("conversation") + .statestore("memory-store") + .build()) + .llm(LlmMetadata.builder() + .client("openai") + .provider("openai") + .api("chat") + .model("gpt-4") + .baseUrl("https://api.openai.com") + .promptTemplate("Answer: {input}") + .build()) + .registry(RegistryMetadata.builder() + .statestore("registry-store") + .name("team-registry") + .build()) + .addTool(ToolMetadata.builder() + .toolName("search") + .toolDescription("Search the web") + .toolArgs("{\"query\": \"string\"}") + .build()) + .addTool(ToolMetadata.builder() + .toolName("calculator") + .toolDescription("Perform calculations") + .toolArgs("{\"expression\": \"string\"}") + .build()) + .maxIterations(10) + .toolChoice("auto") + .agentMetadata(Map.of("version", "1.0", "team", "alpha")) + .build(); + + assertThat(schema.getAgent().isOrchestrator()).isTrue(); + assertThat(schema.getAgent().getRole()).isEqualTo("coordinator"); + assertThat(schema.getAgent().getGoal()).isEqualTo("Coordinate tasks across agents"); + assertThat(schema.getAgent().getInstructions()).containsExactly("Be concise", "Delegate work"); + assertThat(schema.getAgent().getStatestore()).isEqualTo("statestore"); + assertThat(schema.getAgent().getSystemPrompt()).isEqualTo("You are a coordinator agent."); + assertThat(schema.getAgent().getFramework()).isEqualTo("langchain4j"); + assertThat(schema.getPubsub().getName()).isEqualTo("pubsub"); + assertThat(schema.getPubsub().getBroadcastTopic()).isEqualTo("broadcast"); + assertThat(schema.getPubsub().getAgentTopic()).isEqualTo("agent-messages"); + assertThat(schema.getMemory().getType()).isEqualTo("conversation"); + assertThat(schema.getMemory().getStatestore()).isEqualTo("memory-store"); + assertThat(schema.getLlm().getClient()).isEqualTo("openai"); + assertThat(schema.getLlm().getProvider()).isEqualTo("openai"); + assertThat(schema.getLlm().getApi()).isEqualTo("chat"); + assertThat(schema.getLlm().getModel()).isEqualTo("gpt-4"); + assertThat(schema.getLlm().getBaseUrl()).isEqualTo("https://api.openai.com"); + assertThat(schema.getLlm().getPromptTemplate()).isEqualTo("Answer: {input}"); + assertThat(schema.getRegistry().getStatestore()).isEqualTo("registry-store"); + assertThat(schema.getRegistry().getName()).isEqualTo("team-registry"); + assertThat(schema.getTools()).hasSize(2); + assertThat(schema.getTools().get(0).getToolName()).isEqualTo("search"); + assertThat(schema.getTools().get(1).getToolName()).isEqualTo("calculator"); + assertThat(schema.getMaxIterations()).isEqualTo(10); + assertThat(schema.getToolChoice()).isEqualTo("auto"); + assertThat(schema.getAgentMetadata()).containsEntry("version", "1.0"); + } + + @Test + void buildLlmWithAzureConfig() { + LlmMetadata llm = LlmMetadata.builder() + .client("azure-openai") + .provider("azure") + .azureEndpoint("https://my-resource.openai.azure.com") + .azureDeployment("gpt-4-deployment") + .componentName("llm-component") + .build(); + + assertThat(llm.getAzureEndpoint()).isEqualTo("https://my-resource.openai.azure.com"); + assertThat(llm.getAzureDeployment()).isEqualTo("gpt-4-deployment"); + assertThat(llm.getComponentName()).isEqualTo("llm-component"); + assertThat(llm.getApi()).isEqualTo("unknown"); + assertThat(llm.getModel()).isEqualTo("unknown"); + } + + @Test + void buildLlmWithDefaults() { + LlmMetadata llm = LlmMetadata.builder() + .client("openai") + .provider("openai") + .build(); + + assertThat(llm.getApi()).isEqualTo("unknown"); + assertThat(llm.getModel()).isEqualTo("unknown"); + assertThat(llm.getComponentName()).isNull(); + assertThat(llm.getBaseUrl()).isNull(); + } + + @Test + void schemaBuilderRequiresAllRequiredFields() { + assertThatThrownBy(() -> AgentMetadataSchema.builder().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("schemaVersion, agent, name, and registeredAt are required"); + } + + @Test + void agentBuilderRequiresAppIdAndType() { + assertThatThrownBy(() -> AgentMetadata.builder().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("appId and type are required"); + } + + @Test + void llmBuilderRequiresClientAndProvider() { + assertThatThrownBy(() -> LlmMetadata.builder().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("client and provider are required"); + } + + @Test + void memoryBuilderRequiresType() { + assertThatThrownBy(() -> MemoryMetadata.builder().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("type is required"); + } + + @Test + void pubsubBuilderRequiresName() { + assertThatThrownBy(() -> PubSubMetadata.builder().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("name is required"); + } + + @Test + void toolBuilderRequiresAllFields() { + assertThatThrownBy(() -> ToolMetadata.builder().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("toolName, toolDescription, and toolArgs are required"); + } + + @Test + void registryBuilderHasNoRequiredFields() { + RegistryMetadata registry = RegistryMetadata.builder().build(); + assertThat(registry.getStatestore()).isNull(); + assertThat(registry.getName()).isNull(); + } + + @Test + void serializeMinimalSchemaToJson() throws Exception { + AgentMetadataSchema schema = AgentMetadataSchema.builder() + .schemaVersion("0.11.1") + .name("test-agent") + .registeredAt("2025-01-01T00:00:00Z") + .agent(AgentMetadata.builder() + .appId("my-app") + .type("standalone") + .build()) + .build(); + + String json = mapper.writeValueAsString(schema); + + assertThat(json).contains("\"schema_version\":\"0.11.1\""); + assertThat(json).contains("\"name\":\"test-agent\""); + assertThat(json).contains("\"registered_at\":\"2025-01-01T00:00:00Z\""); + assertThat(json).contains("\"appid\":\"my-app\""); + assertThat(json).contains("\"type\":\"standalone\""); + // null fields should be excluded + assertThat(json).doesNotContain("\"pubsub\""); + assertThat(json).doesNotContain("\"memory\""); + assertThat(json).doesNotContain("\"llm\""); + assertThat(json).doesNotContain("\"registry\""); + assertThat(json).doesNotContain("\"tools\""); + assertThat(json).doesNotContain("\"max_iterations\""); + assertThat(json).doesNotContain("\"tool_choice\""); + assertThat(json).doesNotContain("\"agent_metadata\""); + } + + @Test + void serializeAndDeserializeFullSchema() throws Exception { + AgentMetadataSchema original = AgentMetadataSchema.builder() + .schemaVersion("0.11.1") + .name("round-trip-agent") + .registeredAt("2025-06-15T10:30:00Z") + .agent(AgentMetadata.builder() + .appId("rt-app") + .type("durable") + .orchestrator(true) + .role("planner") + .goal("Plan tasks") + .instructions(List.of("Step 1", "Step 2")) + .statestore("state-store") + .systemPrompt("You are a planner.") + .framework("quarkus") + .build()) + .pubsub(PubSubMetadata.builder() + .name("pubsub") + .broadcastTopic("broadcast") + .agentTopic("agent-topic") + .build()) + .memory(MemoryMetadata.builder() + .type("buffer") + .statestore("mem-store") + .build()) + .llm(LlmMetadata.builder() + .client("openai") + .provider("openai") + .api("chat") + .model("gpt-4o") + .baseUrl("https://api.openai.com") + .build()) + .registry(RegistryMetadata.builder() + .statestore("reg-store") + .name("my-registry") + .build()) + .addTool(ToolMetadata.builder() + .toolName("search") + .toolDescription("Web search") + .toolArgs("{}") + .build()) + .maxIterations(5) + .toolChoice("auto") + .agentMetadata(Map.of("env", "prod")) + .build(); + + String json = mapper.writeValueAsString(original); + AgentMetadataSchema deserialized = mapper.readValue(json, AgentMetadataSchema.class); + + assertThat(deserialized.getSchemaVersion()).isEqualTo("0.11.1"); + assertThat(deserialized.getName()).isEqualTo("round-trip-agent"); + assertThat(deserialized.getRegisteredAt()).isEqualTo("2025-06-15T10:30:00Z"); + assertThat(deserialized.getAgent().getAppId()).isEqualTo("rt-app"); + assertThat(deserialized.getAgent().getType()).isEqualTo("durable"); + assertThat(deserialized.getAgent().isOrchestrator()).isTrue(); + assertThat(deserialized.getAgent().getRole()).isEqualTo("planner"); + assertThat(deserialized.getAgent().getGoal()).isEqualTo("Plan tasks"); + assertThat(deserialized.getAgent().getInstructions()).containsExactly("Step 1", "Step 2"); + assertThat(deserialized.getAgent().getStatestore()).isEqualTo("state-store"); + assertThat(deserialized.getAgent().getSystemPrompt()).isEqualTo("You are a planner."); + assertThat(deserialized.getAgent().getFramework()).isEqualTo("quarkus"); + assertThat(deserialized.getPubsub().getName()).isEqualTo("pubsub"); + assertThat(deserialized.getPubsub().getBroadcastTopic()).isEqualTo("broadcast"); + assertThat(deserialized.getPubsub().getAgentTopic()).isEqualTo("agent-topic"); + assertThat(deserialized.getMemory().getType()).isEqualTo("buffer"); + assertThat(deserialized.getMemory().getStatestore()).isEqualTo("mem-store"); + assertThat(deserialized.getLlm().getClient()).isEqualTo("openai"); + assertThat(deserialized.getLlm().getModel()).isEqualTo("gpt-4o"); + assertThat(deserialized.getLlm().getBaseUrl()).isEqualTo("https://api.openai.com"); + assertThat(deserialized.getRegistry().getStatestore()).isEqualTo("reg-store"); + assertThat(deserialized.getRegistry().getName()).isEqualTo("my-registry"); + assertThat(deserialized.getTools()).hasSize(1); + assertThat(deserialized.getTools().get(0).getToolName()).isEqualTo("search"); + assertThat(deserialized.getMaxIterations()).isEqualTo(5); + assertThat(deserialized.getToolChoice()).isEqualTo("auto"); + assertThat(deserialized.getAgentMetadata()).containsEntry("env", "prod"); + } + + @Test + void deserializeFromJson() throws Exception { + String json = """ + { + "schema_version": "0.11.1", + "name": "json-agent", + "registered_at": "2025-03-01T00:00:00Z", + "agent": { + "appid": "json-app", + "type": "standalone", + "orchestrator": false, + "role": "worker", + "goal": "Process tasks", + "instructions": ["Follow orders"], + "system_prompt": "You are a worker.", + "framework": "dapr" + }, + "llm": { + "client": "anthropic", + "provider": "anthropic", + "model": "claude-3" + }, + "tools": [ + { + "tool_name": "fetch", + "tool_description": "Fetch URL", + "tool_args": "{\\"url\\": \\"string\\"}" + } + ], + "max_iterations": 20, + "tool_choice": "required" + } + """; + + AgentMetadataSchema schema = mapper.readValue(json, AgentMetadataSchema.class); + + assertThat(schema.getSchemaVersion()).isEqualTo("0.11.1"); + assertThat(schema.getName()).isEqualTo("json-agent"); + assertThat(schema.getAgent().getAppId()).isEqualTo("json-app"); + assertThat(schema.getAgent().getType()).isEqualTo("standalone"); + assertThat(schema.getAgent().isOrchestrator()).isFalse(); + assertThat(schema.getAgent().getRole()).isEqualTo("worker"); + assertThat(schema.getAgent().getGoal()).isEqualTo("Process tasks"); + assertThat(schema.getAgent().getInstructions()).containsExactly("Follow orders"); + assertThat(schema.getAgent().getSystemPrompt()).isEqualTo("You are a worker."); + assertThat(schema.getAgent().getFramework()).isEqualTo("dapr"); + assertThat(schema.getLlm().getClient()).isEqualTo("anthropic"); + assertThat(schema.getLlm().getProvider()).isEqualTo("anthropic"); + assertThat(schema.getLlm().getModel()).isEqualTo("claude-3"); + assertThat(schema.getTools()).hasSize(1); + assertThat(schema.getTools().get(0).getToolName()).isEqualTo("fetch"); + assertThat(schema.getMaxIterations()).isEqualTo(20); + assertThat(schema.getToolChoice()).isEqualTo("required"); + assertThat(schema.getPubsub()).isNull(); + assertThat(schema.getMemory()).isNull(); + assertThat(schema.getRegistry()).isNull(); + } + + @Test + void addToolIncrementally() { + AgentMetadataSchema schema = AgentMetadataSchema.builder() + .schemaVersion("0.11.1") + .name("tool-agent") + .registeredAt("2025-01-01T00:00:00Z") + .agent(AgentMetadata.builder() + .appId("tool-app") + .type("standalone") + .build()) + .addTool(ToolMetadata.builder() + .toolName("t1") + .toolDescription("First tool") + .toolArgs("{}") + .build()) + .addTool(ToolMetadata.builder() + .toolName("t2") + .toolDescription("Second tool") + .toolArgs("{}") + .build()) + .addTool(ToolMetadata.builder() + .toolName("t3") + .toolDescription("Third tool") + .toolArgs("{}") + .build()) + .build(); + + assertThat(schema.getTools()).hasSize(3); + assertThat(schema.getTools()).extracting(ToolMetadata::getToolName) + .containsExactly("t1", "t2", "t3"); + } +} \ No newline at end of file diff --git a/quarkus/quarkus-agentic-dapr-agents-registry/src/test/java/io/quarkiverse/dapr/agents/registry/service/AgentRegistryDevServicesTest.java b/quarkus/quarkus-agentic-dapr-agents-registry/src/test/java/io/quarkiverse/dapr/agents/registry/service/AgentRegistryDevServicesTest.java new file mode 100644 index 0000000000..7a155c65bc --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/test/java/io/quarkiverse/dapr/agents/registry/service/AgentRegistryDevServicesTest.java @@ -0,0 +1,120 @@ +package io.quarkiverse.dapr.agents.registry.service; + +import io.dapr.client.DaprClient; +import io.dapr.client.domain.State; +import io.quarkiverse.dapr.agents.registry.model.AgentMetadataSchema; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Integration tests for {@link AgentRegistry} using Dapr dev services. + *

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

+ * Methods have no parameters to avoid validation errors from the langchain4j + * AgenticProcessor which expects parameters to resolve from agent output keys. + */ +public interface TestAgent { + + @Agent(name = "test-agent-with-prompt", description = "Agent with system prompt") + @SystemMessage("You are a test agent for integration testing.") + String chatWithPrompt(); + + @Agent(name = "test-agent-simple", description = "Simple agent without prompt") + String chatSimple(); + + @Agent(description = "Agent with default name") + String defaultNameAgent(); +} diff --git a/quarkus/quarkus-agentic-dapr-agents-registry/src/test/java/io/quarkiverse/dapr/agents/registry/service/TestAgentBean.java b/quarkus/quarkus-agentic-dapr-agents-registry/src/test/java/io/quarkiverse/dapr/agents/registry/service/TestAgentBean.java new file mode 100644 index 0000000000..7edb7721ba --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/test/java/io/quarkiverse/dapr/agents/registry/service/TestAgentBean.java @@ -0,0 +1,30 @@ +package io.quarkiverse.dapr.agents.registry.service; + +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Alternative; + +/** + * CDI bean implementing {@link TestAgent} so that {@link AgentRegistry} + * can discover it via {@code BeanManager} during startup. + */ +@Alternative +@Priority(1) +@ApplicationScoped +public class TestAgentBean implements TestAgent { + + @Override + public String chatWithPrompt() { + return "mock response"; + } + + @Override + public String chatSimple() { + return "mock response"; + } + + @Override + public String defaultNameAgent() { + return "mock response"; + } +} diff --git a/quarkus/quarkus-agentic-dapr-agents-registry/src/test/resources/application.properties b/quarkus/quarkus-agentic-dapr-agents-registry/src/test/resources/application.properties new file mode 100644 index 0000000000..c9b76a36a6 --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/test/resources/application.properties @@ -0,0 +1,13 @@ +# Dapr Dev Services with PostgreSQL state store (dashboard enabled = uses PostgreSQL) +quarkus.dapr.devservices.enabled=true +quarkus.dapr.devservices.dashboard.enabled=true + +# Agent registry configuration +# Dev services name the state store component "kvstore" +dapr.agents.statestore=kvstore +dapr.appid=local-dapr-app +dapr.agents.team=test-team + +# Dummy OpenAI key (MockChatModel overrides the real provider in tests) +quarkus.langchain4j.openai.api-key=test-key +quarkus.langchain4j.openai.chat-model.model-name=gpt-4o-mini diff --git a/quarkus/runtime/pom.xml b/quarkus/runtime/pom.xml new file mode 100644 index 0000000000..d58450e0d9 --- /dev/null +++ b/quarkus/runtime/pom.xml @@ -0,0 +1,88 @@ + + + 4.0.0 + + io.dapr.quarkus + dapr-quarkus-agentic-parent + 1.18.0-SNAPSHOT + ../pom.xml + + + quarkus-agentic-dapr + Dapr Quarkus 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/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/AgentRunContext.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/AgentRunContext.java new file mode 100644 index 0000000000..f69296be72 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/AgentRunContext.java @@ -0,0 +1,146 @@ +/* + * 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.quarkiverse.dapr.langchain4j.agent; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Holds the synchronization state for a single agent execution. + * + *

When a {@code @Tool}-annotated method is intercepted by {@link DaprToolCallInterceptor}, + * it registers a {@link PendingCall} here and blocks until + * {@link io.quarkiverse.dapr.langchain4j.agent.activities.ToolCallActivity} executes the + * tool on the Dapr Workflow Activity thread and completes the future. + */ +public class AgentRunContext { + + /** + * Holds all the information needed for {@code ToolCallActivity} to execute the tool + * and unblock the waiting agent thread. + */ + @SuppressWarnings("EI_EXPOSE_REP") + public record PendingCall( + Object target, + Method method, + Object[] args, + CompletableFuture resultFuture) { + + /** + * Creates a PendingCall with a defensive copy of args. + * + * @param target the object instance on which the method will be invoked + * @param method the reflective method handle + * @param args the arguments to pass to the method + * @param resultFuture future to complete when the call finishes + */ + public PendingCall(Object target, Method method, Object[] args, + CompletableFuture resultFuture) { + this.target = target; + this.method = method; + this.args = args == null ? null : Arrays.copyOf(args, args.length); + this.resultFuture = resultFuture; + } + + /** + * Returns a defensive copy of args. + * + * @return copy of args array + */ + @Override + public Object[] args() { + return args == null ? null : Arrays.copyOf(args, args.length); + } + } + + private final String agentRunId; + private final Map pendingCalls = new ConcurrentHashMap<>(); + + /** + * Creates a new AgentRunContext for the given agent run ID. + * + * @param agentRunId the unique identifier for the agent run + */ + public AgentRunContext(String agentRunId) { + this.agentRunId = agentRunId; + } + + /** + * Returns the agent run ID associated with this context. + * + * @return the agent run ID + */ + public String getAgentRunId() { + return agentRunId; + } + + /** + * Register a pending tool call and return the future that will be completed by + * {@code ToolCallActivity} once the tool has executed. + * + * @param toolCallId the unique identifier for this tool call + * @param target the object instance on which the tool method will be invoked + * @param method the reflective method handle for the tool + * @param args the arguments to pass to the tool method + * @return a future that completes with the tool execution result + */ + public CompletableFuture registerCall( + String toolCallId, Object target, Method method, Object[] args) { + CompletableFuture future = new CompletableFuture<>(); + pendingCalls.put(toolCallId, new PendingCall(target, method, args, future)); + return future; + } + + /** + * Returns the pending call for the given tool call ID without removing it. + * Used by {@code ToolCallActivity} to retrieve call details. + * + * @param toolCallId the unique identifier for the tool call to look up + * @return the pending call, or {@code null} if no call exists for the given ID + */ + public PendingCall getPendingCall(String toolCallId) { + return pendingCalls.get(toolCallId); + } + + /** + * Complete the pending call with a successful result. Removes the entry and + * unblocks the agent thread waiting in {@link DaprToolCallInterceptor}. + * + * @param toolCallId the unique identifier for the tool call to complete + * @param result the result value to deliver to the waiting thread + */ + public void completeCall(String toolCallId, Object result) { + PendingCall call = pendingCalls.remove(toolCallId); + if (call != null) { + call.resultFuture().complete(result); + } + } + + /** + * Complete the pending call with an exception. Removes the entry and + * propagates the failure to the waiting agent thread. + * + * @param toolCallId the unique identifier for the tool call to fail + * @param cause the exception to propagate to the waiting thread + */ + public void failCall(String toolCallId, Throwable cause) { + PendingCall call = pendingCalls.remove(toolCallId); + if (call != null) { + call.resultFuture().completeExceptionally(cause); + } + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/AgentRunLifecycleManager.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/AgentRunLifecycleManager.java new file mode 100644 index 0000000000..5905dbc8a3 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/AgentRunLifecycleManager.java @@ -0,0 +1,137 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.quarkiverse.dapr.langchain4j.agent; + +import io.dapr.workflows.client.DaprWorkflowClient; +import io.quarkiverse.dapr.langchain4j.agent.workflow.AgentEvent; +import io.quarkiverse.dapr.langchain4j.agent.workflow.AgentRunInput; +import io.quarkiverse.dapr.langchain4j.agent.workflow.AgentRunWorkflow; +import io.quarkiverse.dapr.langchain4j.workflow.WorkflowNameResolver; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; +import org.jboss.logging.Logger; + +import java.util.UUID; +/** + * Request-scoped CDI bean that manages the lifecycle of a lazily-started + * {@link AgentRunWorkflow} for standalone {@code @Agent} invocations. + * + *

Why this exists

+ * {@code @Agent} interfaces in quarkus-langchain4j are registered as synthetic beans + * (via {@code SyntheticBeanBuildItem}) without interception enabled. This means CDI interceptors + * such as {@code DaprAgentMethodInterceptor} cannot fire on {@code @Agent} method calls. + * + *

Instead, {@link DaprToolCallInterceptor} calls {@link #getOrActivate()} on the first + * {@code @Tool} method call it intercepts within a request that has no active Dapr agent context. + * This lazily starts the {@link AgentRunWorkflow} and sets {@link DaprAgentContextHolder} so + * that all subsequent tool calls within the same request are also routed through Dapr. + * + *

When the CDI request scope is destroyed (i.e., after the HTTP response is sent), + * {@link #cleanup()} sends the {@code "done"} event that terminates the {@link AgentRunWorkflow}. + */ + +@RequestScoped +public class AgentRunLifecycleManager { + + private static final Logger LOG = Logger.getLogger(AgentRunLifecycleManager.class); + + @Inject + DaprWorkflowClient workflowClient; + + private String agentRunId; + + /** + * Returns the active agent run ID for this request, lazily starting an + * {@link AgentRunWorkflow} if one has not been created yet. + * + *

This overload accepts the agent name and prompt metadata extracted from the + * {@code @Agent}, {@code @UserMessage}, and {@code @SystemMessage} annotations (CDI bean + * path) or from the rendered {@code ChatRequest} messages (AiService path). + * + * @param agentName the value of {@code @Agent(name)}, or {@code null} / blank to use + * {@code "standalone"} + * @param userMessage the user-message template or rendered text; may be {@code null} + * @param systemMessage the system-message template or rendered text; may be {@code null} + * @return the active agent run ID + */ + public String getOrActivate(String agentName, String userMessage, String systemMessage) { + if (agentRunId == null) { + agentRunId = UUID.randomUUID().toString(); + String name = (agentName != null && !agentName.isBlank()) ? agentName : "standalone"; + AgentRunContext runContext = new AgentRunContext(agentRunId); + DaprAgentRunRegistry.register(agentRunId, runContext); + workflowClient.scheduleNewWorkflow( + WorkflowNameResolver.resolve(AgentRunWorkflow.class), + new AgentRunInput(agentRunId, name, userMessage, systemMessage), agentRunId); + DaprAgentContextHolder.set(agentRunId); + LOG.infof("[AgentRun:%s] AgentRunWorkflow started (lazy — standalone @Agent), agent=%s", + agentRunId, name); + } + return agentRunId; + } + + /** + * Returns the active agent run ID for this request, lazily starting an + * {@link AgentRunWorkflow} if one has not been created yet. + * + *

Uses {@code "standalone"} as the agent name and {@code null} for prompt metadata. + * Prefer {@link #getOrActivate(String, String, String)} when agent metadata is available. + * + * @return the active agent run ID + */ + public String getOrActivate() { + return getOrActivate(null, null, null); + } + + /** + * Signals the active {@link AgentRunWorkflow} that the {@code @Agent} method has finished, + * then unregisters the run and clears the context holder. + * + *

Called directly by the generated CDI decorator when the {@code @Agent} method + * exits (successfully or via exception). Setting {@code agentRunId} to {@code null} afterward + * makes {@link #cleanup()} a no-op, preventing a duplicate {@code "done"} event. + * + *

When no decorator was generated (e.g., the lazy-activation fallback path used by + * {@link DaprChatModelDecorator}), this method is called by {@link #cleanup()} when the + * CDI request scope ends. + */ + public void triggerDone() { + if (agentRunId != null) { + LOG.infof("[AgentRun:%s] @Agent method exited — sending done event to AgentRunWorkflow", + agentRunId); + try { + workflowClient.raiseEvent(agentRunId, "agent-event", + new AgentEvent("done", null, null, null)); + } finally { + DaprAgentRunRegistry.unregister(agentRunId); + DaprAgentContextHolder.clear(); + agentRunId = null; // prevents @PreDestroy from firing a second time + } + } + } + + /** + * Safety-net called when the CDI request scope is destroyed. + * + *

In the normal flow the generated CDI decorator already called {@link #triggerDone()}, + * so {@code agentRunId} is {@code null} and this method is a no-op. It only fires the + * {@code "done"} event when the lazy-activation fallback path was used (i.e., no decorator + * was present for this {@code @Agent} interface). + */ + @PreDestroy + void cleanup() { + triggerDone(); + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentContextHolder.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentContextHolder.java new file mode 100644 index 0000000000..d26d54c1d4 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentContextHolder.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.quarkiverse.dapr.langchain4j.agent; + +/** + * Thread-local holder for the current Dapr agent run ID. + * + *

Set by {@link io.quarkiverse.dapr.langchain4j.workflow.DaprWorkflowPlanner} before an agent + * begins execution, so that {@link DaprToolCallInterceptor} can detect when a tool call + * is happening inside a Dapr-backed agent and route it through a Dapr Workflow Activity. + */ +public class DaprAgentContextHolder { + + private static final ThreadLocal AGENT_RUN_ID = new ThreadLocal<>(); + + private DaprAgentContextHolder() { + } + + /** + * Sets the agent run ID for the current thread. + * + * @param agentRunId the agent run ID to set + */ + public static void set(String agentRunId) { + AGENT_RUN_ID.set(agentRunId); + } + + /** + * Returns the agent run ID for the current thread. + * + * @return the agent run ID, or {@code null} if not set + */ + public static String get() { + return AGENT_RUN_ID.get(); + } + + /** + * Clears the agent run ID for the current thread. + */ + public static void clear() { + AGENT_RUN_ID.remove(); + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentInterceptorBinding.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentInterceptorBinding.java new file mode 100644 index 0000000000..127cd557ca --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentInterceptorBinding.java @@ -0,0 +1,39 @@ +/* + * 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.quarkiverse.dapr.langchain4j.agent; + +import jakarta.interceptor.InterceptorBinding; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * CDI interceptor binding that marks an {@code @Agent}-annotated method for + * automatic Dapr Workflow integration. + * + *

Applied at build time by {@code DaprAgenticProcessor} to all interface methods + * carrying the {@code @Agent} annotation. This causes {@link DaprAgentMethodInterceptor} + * to fire when the method is called, starting an + * {@link io.quarkiverse.dapr.langchain4j.agent.workflow.AgentRunWorkflow} + * so that every tool call the agent makes runs inside a Dapr Workflow Activity. + */ +@InterceptorBinding +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface DaprAgentInterceptorBinding { +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentMetadataHolder.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentMetadataHolder.java new file mode 100644 index 0000000000..95b255a849 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentMetadataHolder.java @@ -0,0 +1,63 @@ +/* + * 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.quarkiverse.dapr.langchain4j.agent; + +/** + * Thread-local holder for {@code @Agent} annotation metadata. + * + *

The generated CDI decorator sets this at the start of every {@code @Agent} method call + * so that {@link DaprChatModelDecorator} can retrieve the real agent name, user message, + * and system message when it lazily activates a workflow — instead of falling back to + * {@code "standalone"} with {@code null} messages. + */ +public final class DaprAgentMetadataHolder { + + /** + * Metadata record for agent name, user message, and system message. + */ + public record AgentMetadata(String agentName, String userMessage, String systemMessage) { + } + + private static final ThreadLocal METADATA = new ThreadLocal<>(); + + private DaprAgentMetadataHolder() { + } + + /** + * Sets the agent metadata for the current thread. + * + * @param agentName the agent name + * @param userMessage the user message template + * @param systemMessage the system message template + */ + public static void set(String agentName, String userMessage, String systemMessage) { + METADATA.set(new AgentMetadata(agentName, userMessage, systemMessage)); + } + + /** + * Returns the agent metadata for the current thread. + * + * @return the agent metadata, or {@code null} if not set + */ + public static AgentMetadata get() { + return METADATA.get(); + } + + /** + * Clears the agent metadata for the current thread. + */ + public static void clear() { + METADATA.remove(); + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentMethodInterceptor.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentMethodInterceptor.java new file mode 100644 index 0000000000..4a5502d8f6 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentMethodInterceptor.java @@ -0,0 +1,139 @@ +/* + * 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.quarkiverse.dapr.langchain4j.agent; + +import dev.langchain4j.agentic.Agent; +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import io.dapr.workflows.client.DaprWorkflowClient; +import io.quarkiverse.dapr.langchain4j.agent.workflow.AgentEvent; +import io.quarkiverse.dapr.langchain4j.agent.workflow.AgentRunInput; +import io.quarkiverse.dapr.langchain4j.agent.workflow.AgentRunWorkflow; +import io.quarkiverse.dapr.langchain4j.workflow.WorkflowNameResolver; +import jakarta.annotation.Priority; +import jakarta.inject.Inject; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InvocationContext; +import org.jboss.logging.Logger; + +import java.lang.reflect.Method; +import java.util.UUID; + +/** + * CDI interceptor that starts a Dapr {@link AgentRunWorkflow} for any standalone + * {@code @Agent}-annotated method invocation. + * + *

Note: In practice this interceptor only fires when the {@code @Agent} + * method belongs to a regular CDI bean. The quarkus-langchain4j agentic extension + * registers {@code @Agent} interfaces as synthetic beans (via + * {@code SyntheticBeanBuildItem}) without interception enabled, so this interceptor will + * not fire for typical {@code @Agent} AiService calls. + * + *

For standalone {@code @Agent} calls, the workflow lifecycle is instead managed lazily by + * {@link AgentRunLifecycleManager}, which is triggered from + * {@link DaprToolCallInterceptor} on the first {@code @Tool} call of the request. + * + *

This class is retained for use cases where {@code @Agent} methods are declared on + * regular CDI beans (not synthetic AiService beans), and for potential future quarkus-langchain4j + * releases that enable interception on AiService synthetic beans. + */ +@DaprAgentInterceptorBinding +@Interceptor +@Priority(Interceptor.Priority.APPLICATION) +public class DaprAgentMethodInterceptor { + + private static final Logger LOG = Logger.getLogger(DaprAgentMethodInterceptor.class); + + @Inject + DaprWorkflowClient workflowClient; + + /** + * Intercepts {@code @Agent}-annotated method calls and starts a Dapr workflow. + * + * @param ctx the invocation context + * @return the result of the intercepted method + * @throws Exception if the intercepted method throws + */ + @AroundInvoke + public Object intercept(InvocationContext ctx) throws Exception { + // If already inside an orchestration-driven agent run (AgentExecutionActivity set this), + // don't start another workflow — just proceed. + if (DaprAgentContextHolder.get() != null) { + return ctx.proceed(); + } + + // Standalone @Agent call — start a new AgentRunWorkflow for this invocation. + String agentRunId = UUID.randomUUID().toString(); + Method method = ctx.getMethod(); + String agentName = extractAgentName(method, ctx.getTarget().getClass()); + String userMessage = extractUserMessageTemplate(method); + String systemMessage = extractSystemMessageTemplate(method); + + LOG.infof("[AgentRun:%s] DaprAgentMethodInterceptor: starting AgentRunWorkflow for %s", + agentRunId, agentName); + + AgentRunContext runContext = new AgentRunContext(agentRunId); + DaprAgentRunRegistry.register(agentRunId, runContext); + workflowClient.scheduleNewWorkflow( + WorkflowNameResolver.resolve(AgentRunWorkflow.class), + new AgentRunInput(agentRunId, agentName, userMessage, systemMessage), agentRunId); + DaprAgentContextHolder.set(agentRunId); + + try { + return ctx.proceed(); + } finally { + LOG.infof("[AgentRun:%s] DaprAgentMethodInterceptor: @Agent method completed, sending done event", + agentRunId); + workflowClient.raiseEvent(agentRunId, "agent-event", + new AgentEvent("done", null, null, null)); + DaprAgentRunRegistry.unregister(agentRunId); + DaprAgentContextHolder.clear(); + } + } + + /** + * Returns the {@code @Agent(name)} value if non-blank, otherwise falls back to + * {@code DeclaringInterface.methodName} for CDI beans. + */ + private String extractAgentName(Method method, Class targetClass) { + Agent agentAnnotation = method.getAnnotation(Agent.class); + if (agentAnnotation != null && !agentAnnotation.name().isBlank()) { + return agentAnnotation.name(); + } + return targetClass.getSimpleName() + "." + method.getName(); + } + + /** + * Returns the joined {@code @UserMessage} template text, or {@code null} if not present. + */ + private String extractUserMessageTemplate(Method method) { + UserMessage annotation = method.getAnnotation(UserMessage.class); + if (annotation != null && annotation.value().length > 0) { + return String.join("\n", annotation.value()); + } + return null; + } + + /** + * Returns the joined {@code @SystemMessage} template text, or {@code null} if not present. + */ + private String extractSystemMessageTemplate(Method method) { + SystemMessage annotation = method.getAnnotation(SystemMessage.class); + if (annotation != null && annotation.value().length > 0) { + return String.join("\n", annotation.value()); + } + return null; + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentRunRegistry.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentRunRegistry.java new file mode 100644 index 0000000000..f098bc3b13 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentRunRegistry.java @@ -0,0 +1,72 @@ +/* + * 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.quarkiverse.dapr.langchain4j.agent; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Static registry that maps agent run IDs to their {@link AgentRunContext}. + * + *

Similar to {@link io.quarkiverse.dapr.langchain4j.workflow.DaprPlannerRegistry} but for + * individual agent executions. Allows + * {@link io.quarkiverse.dapr.langchain4j.agent.activities.ToolCallActivity} + * to look up the in-progress context for a given agent run ID. + */ +public class DaprAgentRunRegistry { + + private static final Map REGISTRY = new ConcurrentHashMap<>(); + + private DaprAgentRunRegistry() { + } + + /** + * Registers an agent run context for the given agent run ID. + * + * @param agentRunId the agent run ID + * @param context the agent run context to register + */ + public static void register(String agentRunId, AgentRunContext context) { + REGISTRY.put(agentRunId, context); + } + + /** + * Returns the agent run context for the given agent run ID. + * + * @param agentRunId the agent run ID + * @return the agent run context, or {@code null} if not registered + */ + public static AgentRunContext get(String agentRunId) { + return REGISTRY.get(agentRunId); + } + + /** + * Unregisters the agent run context for the given agent run ID. + * + * @param agentRunId the agent run ID to unregister + */ + public static void unregister(String agentRunId) { + REGISTRY.remove(agentRunId); + } + + /** + * Returns the set of all registered agent run IDs. + * + * @return the set of registered agent run IDs + */ + public static Set getRegisteredIds() { + return REGISTRY.keySet(); + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentToolInterceptorBinding.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentToolInterceptorBinding.java new file mode 100644 index 0000000000..172072a915 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentToolInterceptorBinding.java @@ -0,0 +1,37 @@ +/* + * 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.quarkiverse.dapr.langchain4j.agent; + +import jakarta.interceptor.InterceptorBinding; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * CDI interceptor binding applied automatically (via Quarkus {@code AnnotationsTransformer}) + * to all {@code @Tool}-annotated methods on CDI beans. + * + *

The corresponding interceptor, {@link DaprToolCallInterceptor}, intercepts these methods + * and, when executing inside a Dapr-backed agent workflow, routes the tool call through + * a Dapr Workflow Activity instead of executing it directly. + */ +@InterceptorBinding +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface DaprAgentToolInterceptorBinding { +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprChatModelDecorator.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprChatModelDecorator.java new file mode 100644 index 0000000000..c9da204ded --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprChatModelDecorator.java @@ -0,0 +1,272 @@ +/* + * 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.quarkiverse.dapr.langchain4j.agent; + +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import io.dapr.workflows.client.DaprWorkflowClient; +import io.quarkiverse.dapr.langchain4j.agent.workflow.AgentEvent; +import jakarta.annotation.Priority; +import jakarta.decorator.Decorator; +import jakarta.decorator.Delegate; +import jakarta.enterprise.context.Dependent; +import jakarta.enterprise.inject.Any; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import jakarta.interceptor.Interceptor; +import org.jboss.logging.Logger; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +/** + * CDI Decorator that routes {@code ChatModel.chat(ChatRequest)} calls through a Dapr + * Workflow Activity when executing inside an active agent run. + * + *

Why a decorator instead of a CDI interceptor

+ * quarkus-langchain4j registers {@code ChatModel} as a synthetic bean + * ({@code SyntheticBeanBuildItem}). Arc does not apply CDI interceptors to synthetic + * beans based on {@code AnnotationsTransformer} modifications to the interface — the + * synthetic bean proxy is generated without interceptor binding metadata. CDI decorators, + * however, work at the bean type level and are applied by Arc to any bean (including + * synthetic beans) whose types include the delegate type. + * + *

Execution flow

+ *
    + *
  1. The LangChain4j AiService calls {@code chatModel.chat(request)} which routes + * through this decorator.
  2. + *
  3. If a Dapr agent run is active (identified by {@link DaprAgentContextHolder}), the + * decorator registers a {@link AgentRunContext.PendingCall} and raises an + * {@code "llm-call"} event to the running + * {@link io.quarkiverse.dapr.langchain4j.agent.workflow.AgentRunWorkflow}.
  4. + *
  5. The decorator blocks the agent thread on a {@link CompletableFuture}.
  6. + *
  7. {@link io.quarkiverse.dapr.langchain4j.agent.activities.LlmCallActivity} picks up + * the event, sets {@link DaprToolCallInterceptor#IS_ACTIVITY_CALL}, and re-invokes + * this decorator's {@code chat()} method via reflection on the stored target.
  8. + *
  9. The decorator sees {@code IS_ACTIVITY_CALL} set and passes through to + * {@code delegate.chat(request)} — executing the real LLM call on the Dapr + * activity thread.
  10. + *
  11. The result is returned to {@code LlmCallActivity}, which completes the future, + * unblocking the agent thread.
  12. + *
+ * + *

Lazy activation

+ * When an {@code @Agent} method is called standalone (no orchestration workflow), + * the first LLM call will find no active {@code agentRunId} in {@link DaprAgentContextHolder}. + * This decorator calls {@link AgentRunLifecycleManager#getOrActivate()} to lazily start + * an {@link io.quarkiverse.dapr.langchain4j.agent.workflow.AgentRunWorkflow} so that all + * subsequent LLM and tool calls are routed through Dapr. + */ + +@Decorator +@Priority(Interceptor.Priority.APPLICATION) +@Dependent +public class DaprChatModelDecorator implements ChatModel { + + private static final Logger LOG = Logger.getLogger(DaprChatModelDecorator.class); + + @Inject + @Delegate + @Any + ChatModel delegate; + + @Inject + DaprWorkflowClient workflowClient; + + @Inject + Instance lifecycleManager; + + /** + * Explicit delegation for the {@code doChat()} template method. + * + *

The default {@link ChatModel#chat(ChatRequest)} implementation calls + * {@code this.doChat(ChatRequest)} internally. Because our decorator only overrides + * {@code chat()}, Arc does not generate a {@code doChat$superforward} bridge method in + * the decorated bean's Arc subclass proxy. Without it, the CDI delegate proxy cannot + * forward {@code doChat()} to the actual bean — it falls through to the interface + * default which throws {@code "Not implemented"}. + * + *

Overriding {@code doChat()} here — even as a pure delegation — causes Arc to generate + * the required bridge, so the internal {@code chat() → doChat()} chain resolves correctly + * through the delegate to the actual {@code ChatModel} implementation. + */ + @Override + public ChatResponse doChat(ChatRequest request) { + return delegate.doChat(request); + } + + @Override + public ChatResponse chat(ChatRequest request) { + // If called from LlmCallActivity (IS_ACTIVITY_CALL is set), this is the real + // execution — pass through to the real ChatModel without routing through Dapr. + if (Boolean.TRUE.equals(DaprToolCallInterceptor.IS_ACTIVITY_CALL.get())) { + return delegate.chat(request); + } + + // Check whether we are inside a Dapr-backed agent run. + String agentRunId = DaprAgentContextHolder.get(); + + if (agentRunId == null) { + // No orchestration context — try to lazily activate a workflow for this request. + // The first event in the ReAct loop is always an LLM call, so this is typically + // where the AgentRunWorkflow is started for standalone @Agent invocations. + // Pass the rendered messages so they are recorded in the workflow input. + agentRunId = tryLazyActivate(extractUserMessage(request), extractSystemMessage(request)); + if (agentRunId == null) { + // Not in a CDI request scope (e.g., background thread) — execute directly. + return delegate.chat(request); + } + } + + AgentRunContext runCtx = DaprAgentRunRegistry.get(agentRunId); + if (runCtx == null) { + return delegate.chat(request); + } + + // Register this LLM call and get a future for the result. + String llmCallId = UUID.randomUUID().toString(); + try { + // Store (this, chat-method, request) so LlmCallActivity can re-invoke + // this decorator's chat() with IS_ACTIVITY_CALL set, which passes through + // to delegate.chat(request) — the real LLM execution. + Method chatMethod = ChatModel.class.getMethod("chat", ChatRequest.class); + CompletableFuture future = runCtx.registerCall( + llmCallId, this, chatMethod, new Object[]{request}); + + // Extract the prompt for observability in the workflow history. + String prompt = extractPrompt(request); + + LOG.infof("[AgentRun:%s][LlmCall:%s] Routing LLM call through Dapr: chat()", + agentRunId, llmCallId); + + // Notify the AgentRunWorkflow that an LLM call is waiting. + // The prompt is passed as args so it is stored in the Dapr activity input. + workflowClient.raiseEvent(agentRunId, "agent-event", + new AgentEvent("llm-call", llmCallId, "chat", prompt)); + + // Block the agent thread until LlmCallActivity completes the LLM execution. + return (ChatResponse) future.join(); + + } catch (NoSuchMethodException ex) { + LOG.warnf("[AgentRun:%s][LlmCall:%s] Could not find chat(ChatRequest) via reflection" + + " — falling back to direct call: %s", agentRunId, llmCallId, ex.getMessage()); + return delegate.chat(request); + } + } + + /** + * Lazily activates an {@link AgentRunLifecycleManager} for the current CDI request scope, + * recording the rendered user and system messages in the workflow input for observability. + * + * @param userMessage the rendered user message extracted from the {@code ChatRequest} + * @param systemMessage the rendered system message extracted from the {@code ChatRequest} + * @return the new {@code agentRunId}, or {@code null} if no request scope is active + */ + private String tryLazyActivate(String userMessage, String systemMessage) { + try { + // Check whether the generated CDI decorator stored @Agent metadata on this thread. + // This provides the real agent name and annotation-level messages even when the + // decorator's own getOrActivate() call failed and fell through to direct delegation. + DaprAgentMetadataHolder.AgentMetadata metadata = DaprAgentMetadataHolder.get(); + String agentName = "standalone"; + if (metadata != null) { + agentName = metadata.agentName(); + if (userMessage == null) { + userMessage = metadata.userMessage(); + } + if (systemMessage == null) { + systemMessage = metadata.systemMessage(); + } + DaprAgentMetadataHolder.clear(); + } + String agentRunId = lifecycleManager.get().getOrActivate(agentName, userMessage, systemMessage); + LOG.infof("[AgentRun:%s] Lazy activation triggered by first LLM call (agent=%s)", + agentRunId, agentName); + return agentRunId; + } catch (Exception ex) { + LOG.debugf("Could not lazily activate AgentRunWorkflow (no active request scope?): %s", + ex.getMessage()); + return null; + } + } + + /** + * Extracts the messages from a {@code ChatRequest} for observability in the workflow history. + * Uses reflection to avoid a hard version-specific dependency on langchain4j internals. + */ + private String extractPrompt(ChatRequest request) { + if (request == null) { + return null; + } + try { + Object messages = request.getClass().getMethod("messages").invoke(request); + return String.valueOf(messages); + } catch (Exception ex) { + return String.valueOf(request); + } + } + + /** + * Extracts the last (most recent) user message text from the {@code ChatRequest}. + * Uses reflection to remain decoupled from specific langchain4j internals. + */ + private String extractUserMessage(ChatRequest request) { + if (request == null) { + return null; + } + try { + List messages = (List) request.getClass().getMethod("messages").invoke(request); + for (int ii = messages.size() - 1; ii >= 0; ii--) { + Object msg = messages.get(ii); + if ("UserMessage".equals(msg.getClass().getSimpleName())) { + try { + return (String) msg.getClass().getMethod("singleText").invoke(msg); + } catch (ReflectiveOperationException ex) { + return String.valueOf(msg); + } + } + } + } catch (ReflectiveOperationException ignored) { + // intentionally empty + } + return null; + } + + /** + * Extracts the system message text from the {@code ChatRequest}. + * Uses reflection to remain decoupled from specific langchain4j internals. + */ + private String extractSystemMessage(ChatRequest request) { + if (request == null) { + return null; + } + try { + List messages = (List) request.getClass().getMethod("messages").invoke(request); + for (Object msg : messages) { + if ("SystemMessage".equals(msg.getClass().getSimpleName())) { + try { + return (String) msg.getClass().getMethod("text").invoke(msg); + } catch (ReflectiveOperationException ex) { + return String.valueOf(msg); + } + } + } + } catch (ReflectiveOperationException ignored) { + // intentionally empty + } + return null; + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprToolCallInterceptor.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprToolCallInterceptor.java new file mode 100644 index 0000000000..95f5d00ac5 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprToolCallInterceptor.java @@ -0,0 +1,145 @@ +/* + * 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.quarkiverse.dapr.langchain4j.agent; + +import io.dapr.workflows.client.DaprWorkflowClient; +import io.quarkiverse.dapr.langchain4j.agent.workflow.AgentEvent; +import jakarta.annotation.Priority; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InvocationContext; +import org.jboss.logging.Logger; + +import java.util.Arrays; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +/** + * CDI interceptor that routes {@code @Tool}-annotated method calls through a Dapr Workflow + * Activity when executing inside a Dapr-backed agent run. + * + *

Execution flow (orchestration-driven)

+ * When an agent is run via an orchestration workflow ({@code @SequenceAgent} etc.), + * {@code AgentExecutionActivity} sets {@link DaprAgentContextHolder} before the agent starts. + * Tool calls find a non-null {@code agentRunId} and are routed through + * {@link io.quarkiverse.dapr.langchain4j.agent.activities.ToolCallActivity}. + * + *

Execution flow (standalone {@code @Agent})

+ * When an {@code @Agent}-annotated method is called directly (without an orchestrator), + * {@link DaprAgentContextHolder} is null on the first tool call. In this case the interceptor + * calls {@link AgentRunLifecycleManager#getOrActivate()} to lazily start an + * {@link io.quarkiverse.dapr.langchain4j.agent.workflow.AgentRunWorkflow} and set the context. + * The workflow is terminated by {@link AgentRunLifecycleManager}'s {@code @PreDestroy} when the + * CDI request scope ends. + * + *

Deadlock prevention

+ * {@code ToolCallActivity} calls the {@code @Tool} method via reflection on the CDI proxy. This + * would cause the interceptor to fire again. The {@link #IS_ACTIVITY_CALL} {@code ThreadLocal} + * prevents recursion: when set on the activity thread, the interceptor calls {@code ctx.proceed()} + * immediately without routing through Dapr. + */ + +@DaprAgentToolInterceptorBinding +@Interceptor +@Priority(Interceptor.Priority.APPLICATION) +public class DaprToolCallInterceptor { + + private static final Logger LOG = Logger.getLogger(DaprToolCallInterceptor.class); + + /** + * Thread-local flag set by {@code ToolCallActivity} to indicate that the current call + * is the actual tool execution (not the routed interception), so the interceptor + * should proceed normally. + */ + public static final ThreadLocal IS_ACTIVITY_CALL = new ThreadLocal<>(); + + @Inject + DaprWorkflowClient workflowClient; + + @Inject + Instance lifecycleManager; + + /** + * Intercepts tool-annotated method calls and routes them through Dapr Workflow. + * + * @param ctx the invocation context + * @return the result of the tool call + * @throws Exception if the tool call fails + */ + @AroundInvoke + public Object intercept(InvocationContext ctx) throws Exception { + // If called from ToolCallActivity, this is the real execution — proceed normally. + if (Boolean.TRUE.equals(IS_ACTIVITY_CALL.get())) { + return ctx.proceed(); + } + + // Check whether we are inside a Dapr-backed agent run. + String agentRunId = DaprAgentContextHolder.get(); + + if (agentRunId == null) { + // No orchestration context — try to lazily activate a workflow for this request. + agentRunId = tryLazyActivate(ctx.getMethod().getName()); + if (agentRunId == null) { + // Not in a CDI request scope (e.g., background thread) — execute directly. + return ctx.proceed(); + } + } + + AgentRunContext runCtx = DaprAgentRunRegistry.get(agentRunId); + if (runCtx == null) { + return ctx.proceed(); + } + + // Register this tool call and get a future for the result. + String toolCallId = UUID.randomUUID().toString(); + final CompletableFuture future = runCtx.registerCall( + toolCallId, + ctx.getTarget(), + ctx.getMethod(), + ctx.getParameters()); + + String args = ""; + if (ctx.getParameters() != null) { + args = Arrays.toString(ctx.getParameters()); + } + + LOG.infof("[AgentRun:%s][ToolCall:%s] Routing tool call through Dapr: method=%s, args=%s", + agentRunId, toolCallId, ctx.getMethod().getName(), args); + + // Notify the AgentRunWorkflow that a tool call is waiting. + workflowClient.raiseEvent(agentRunId, "agent-event", + new AgentEvent("tool-call", toolCallId, ctx.getMethod().getName(), args)); + + // Block the agent thread until ToolCallActivity completes the tool execution. + return future.join(); + } + + /** + * Lazily activates an {@link AgentRunLifecycleManager} for the current CDI request scope. + * Returns the new {@code agentRunId}, or {@code null} if no request scope is active. + */ + private String tryLazyActivate(String toolMethodName) { + try { + String agentRunId = lifecycleManager.get().getOrActivate(); + LOG.infof("[AgentRun:%s] Lazy activation triggered by first tool call: %s", + agentRunId, toolMethodName); + return agentRunId; + } catch (Exception e) { + LOG.debugf("Could not lazily activate AgentRunWorkflow (no active request scope?): %s", + e.getMessage()); + return null; + } + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/LlmCallActivity.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/LlmCallActivity.java new file mode 100644 index 0000000000..816001de8c --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/LlmCallActivity.java @@ -0,0 +1,134 @@ +/* + * 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.quarkiverse.dapr.langchain4j.agent.activities; + +import io.dapr.workflows.WorkflowActivity; +import io.dapr.workflows.WorkflowActivityContext; +import io.quarkiverse.dapr.langchain4j.agent.AgentRunContext; +import io.quarkiverse.dapr.langchain4j.agent.DaprAgentRunRegistry; +import io.quarkiverse.dapr.langchain4j.agent.DaprToolCallInterceptor; +import io.quarkiverse.dapr.workflows.ActivityMetadata; +import jakarta.enterprise.context.ApplicationScoped; +import org.jboss.logging.Logger; + +import java.lang.reflect.InvocationTargetException; +/** + * Dapr Workflow Activity that executes a single {@code ChatModel.chat(ChatRequest)} call on + * behalf of a running {@link io.quarkiverse.dapr.langchain4j.agent.workflow.AgentRunWorkflow}. + * + *

How it works

+ *
    + *
  1. Receives {@link LlmCallInput} with the {@code agentRunId}, {@code llmCallId}, + * {@code methodName}, and the serialized {@code prompt} (messages sent to the LLM).
  2. + *
  3. Looks up the {@link AgentRunContext} from {@link DaprAgentRunRegistry}.
  4. + *
  5. Retrieves the {@link AgentRunContext.PendingCall} registered by + * {@link io.quarkiverse.dapr.langchain4j.agent.DaprLlmCallInterceptor}.
  6. + *
  7. Sets {@link DaprToolCallInterceptor#IS_ACTIVITY_CALL} on this thread so that + * {@code DaprChatModelDecorator} passes through to {@code delegate.chat()} when + * re-invoked via reflection on the stored decorator instance.
  8. + *
  9. Invokes the {@code ChatModel} method via reflection on the decorator instance.
  10. + *
  11. Extracts the response text from the {@code ChatResponse} via reflection + * ({@code aiMessage().text()}) and returns a {@link LlmCallOutput} containing the + * method name and response text — stored in the Dapr workflow history.
  12. + *
  13. Completes the {@code CompletableFuture} in the pending call, unblocking + * the agent thread waiting in {@code DaprChatModelDecorator.chat()}.
  14. + *
+ */ + +@ApplicationScoped +@ActivityMetadata(name = "llm-call") +public class LlmCallActivity implements WorkflowActivity { + + private static final Logger LOG = Logger.getLogger(LlmCallActivity.class); + + /** + * {@inheritDoc} + */ + @Override + public Object run(WorkflowActivityContext ctx) { + LlmCallInput input = ctx.getInput(LlmCallInput.class); + + LOG.infof("[AgentRun:%s][LlmCall:%s] LlmCallActivity started — method=%s", + input.agentRunId(), input.llmCallId(), input.methodName()); + if (input.prompt() != null) { + LOG.debugf("[AgentRun:%s][LlmCall:%s] Prompt:\n%s", + input.agentRunId(), input.llmCallId(), input.prompt()); + } + + AgentRunContext runCtx = DaprAgentRunRegistry.get(input.agentRunId()); + if (runCtx == null) { + throw new IllegalStateException( + "No AgentRunContext found for agentRunId: " + input.agentRunId() + + ". Registered IDs: " + DaprAgentRunRegistry.getRegisteredIds()); + } + + AgentRunContext.PendingCall pendingCall = runCtx.getPendingCall(input.llmCallId()); + if (pendingCall == null) { + throw new IllegalStateException( + "No PendingCall found for llmCallId: " + input.llmCallId() + + " in agentRunId: " + input.agentRunId()); + } + + LOG.infof("[AgentRun:%s][LlmCall:%s] Executing LLM call: %s", + input.agentRunId(), input.llmCallId(), pendingCall.method().getName()); + + // Set the flag so DaprChatModelDecorator passes through on this thread instead of routing. + DaprToolCallInterceptor.IS_ACTIVITY_CALL.set(Boolean.TRUE); + try { + // Invoke chat() on the stored DaprChatModelDecorator instance via reflection. + // IS_ACTIVITY_CALL is set, so the decorator calls delegate.chat() directly. + Object result = pendingCall.method().invoke(pendingCall.target(), pendingCall.args()); + String responseText = extractResponseText(result); + runCtx.completeCall(input.llmCallId(), result); + LOG.infof("[AgentRun:%s][LlmCall:%s] LLM call completed: %s → %s", + input.agentRunId(), input.llmCallId(), pendingCall.method().getName(), responseText); + return new LlmCallOutput(input.methodName(), input.prompt(), responseText); + } catch (InvocationTargetException ite) { + Throwable cause = ite.getCause() != null ? ite.getCause() : ite; + LOG.errorf("[AgentRun:%s][LlmCall:%s] LLM call failed: %s — %s", + input.agentRunId(), input.llmCallId(), pendingCall.method().getName(), cause.getMessage()); + runCtx.failCall(input.llmCallId(), cause); + throw new RuntimeException("LLM call failed: " + pendingCall.method().getName(), cause); + } catch (Exception e) { + LOG.errorf("[AgentRun:%s][LlmCall:%s] LLM call failed: %s — %s", + input.agentRunId(), input.llmCallId(), pendingCall.method().getName(), e.getMessage()); + runCtx.failCall(input.llmCallId(), e); + throw new RuntimeException("LLM call failed: " + pendingCall.method().getName(), e); + } finally { + DaprToolCallInterceptor.IS_ACTIVITY_CALL.remove(); + } + } + + /** + * Extracts the AI response text from a {@code ChatResponse} object using reflection, + * avoiding a hard compile-time dependency on a specific LangChain4j package path. + * Calls {@code chatResponse.aiMessage().text()} if available; falls back to + * {@code String.valueOf(result)} otherwise. + */ + private String extractResponseText(Object result) { + if (result == null) { + return null; + } + try { + Object aiMessage = result.getClass().getMethod("aiMessage").invoke(result); + if (aiMessage != null) { + Object text = aiMessage.getClass().getMethod("text").invoke(aiMessage); + return String.valueOf(text); + } + } catch (ReflectiveOperationException ignored) { + // Not a ChatResponse or missing expected methods — fall through. + } + return String.valueOf(result); + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/LlmCallInput.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/LlmCallInput.java new file mode 100644 index 0000000000..7067400988 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/LlmCallInput.java @@ -0,0 +1,30 @@ +/* + * 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.quarkiverse.dapr.langchain4j.agent.activities; + +/** + * Input record for {@link LlmCallActivity}, identifying the specific LLM call to execute. + * + * @param agentRunId the ID of the {@code AgentRunWorkflow} instance + * @param llmCallId the unique ID of the pending LLM call registered in + * {@link io.quarkiverse.dapr.langchain4j.agent.AgentRunContext} + * @param methodName name of the {@code ChatModel} method being called (e.g., {@code "chat"}); + * stored in the Dapr activity input for observability in the workflow history + * @param prompt string representation of the {@code ChatRequest} messages sent to the LLM; + * extracted by {@link io.quarkiverse.dapr.langchain4j.agent.DaprLlmCallInterceptor} + * and stored in the Dapr activity input so the full prompt is visible in the + * workflow history without needing to inspect in-process state + */ +public record LlmCallInput(String agentRunId, String llmCallId, String methodName, String prompt) { +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/LlmCallOutput.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/LlmCallOutput.java new file mode 100644 index 0000000000..96897f9dca --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/LlmCallOutput.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.quarkiverse.dapr.langchain4j.agent.activities; + +/** + * Output record returned by {@link LlmCallActivity} after a {@code ChatModel.chat()} + * call has been executed. Stored in the Dapr workflow history so the full LLM turn + * (prompt in, response out) is visible without inspecting in-process state. + * + * @param methodName name of the {@code ChatModel} method that was invoked (e.g., {@code "chat"}) + * @param prompt serialized {@code ChatRequest} messages that were sent to the model; + * extracted from the {@code ChatRequest} argument by + * {@link io.quarkiverse.dapr.langchain4j.agent.DaprLlmCallInterceptor} + * @param response AI response text extracted from {@code ChatResponse.aiMessage().text()}; + * this is the exact text the model returned to the agent + */ +public record LlmCallOutput(String methodName, String prompt, String response) { +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/ToolCallActivity.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/ToolCallActivity.java new file mode 100644 index 0000000000..2cecd429b3 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/ToolCallActivity.java @@ -0,0 +1,100 @@ +/* + * 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.quarkiverse.dapr.langchain4j.agent.activities; + +import io.dapr.workflows.WorkflowActivity; +import io.dapr.workflows.WorkflowActivityContext; +import io.quarkiverse.dapr.langchain4j.agent.AgentRunContext; +import io.quarkiverse.dapr.langchain4j.agent.DaprAgentRunRegistry; +import io.quarkiverse.dapr.langchain4j.agent.DaprToolCallInterceptor; +import io.quarkiverse.dapr.workflows.ActivityMetadata; +import jakarta.enterprise.context.ApplicationScoped; +import org.jboss.logging.Logger; + +import java.lang.reflect.InvocationTargetException; +/** + * Dapr Workflow Activity that executes a single {@code @Tool}-annotated method call on + * behalf of a running {@link io.quarkiverse.dapr.langchain4j.agent.workflow.AgentRunWorkflow}. + * + *

How it works

+ *
    + *
  1. Receives {@link ToolCallInput} with the {@code agentRunId} and {@code toolCallId}.
  2. + *
  3. Looks up the {@link AgentRunContext} from {@link DaprAgentRunRegistry}.
  4. + *
  5. Retrieves the {@link AgentRunContext.PendingCall} registered by + * {@link io.quarkiverse.dapr.langchain4j.agent.DaprToolCallInterceptor}.
  6. + *
  7. Sets {@link DaprToolCallInterceptor#IS_ACTIVITY_CALL} on this thread so that + * the CDI interceptor passes through when the method is called via the CDI proxy.
  8. + *
  9. Invokes the {@code @Tool} method via reflection on the CDI proxy.
  10. + *
  11. Completes the {@code CompletableFuture} stored in the pending call, unblocking + * the agent thread waiting in {@code DaprToolCallInterceptor.intercept()}.
  12. + *
+ */ + +@ApplicationScoped +@ActivityMetadata(name = "tool-call") +public class ToolCallActivity implements WorkflowActivity { + + private static final Logger LOG = Logger.getLogger(ToolCallActivity.class); + + @Override + public Object run(WorkflowActivityContext ctx) { + ToolCallInput input = ctx.getInput(ToolCallInput.class); + + LOG.infof("[AgentRun:%s][ToolCall:%s] ToolCallActivity started — tool=%s, args=%s", + input.agentRunId(), input.toolCallId(), input.toolName(), input.args()); + + AgentRunContext runCtx = DaprAgentRunRegistry.get(input.agentRunId()); + if (runCtx == null) { + throw new IllegalStateException( + "No AgentRunContext found for agentRunId: " + input.agentRunId() + + ". Registered IDs: " + DaprAgentRunRegistry.getRegisteredIds()); + } + + AgentRunContext.PendingCall pendingCall = runCtx.getPendingCall(input.toolCallId()); + if (pendingCall == null) { + throw new IllegalStateException( + "No PendingCall found for toolCallId: " + input.toolCallId() + + " in agentRunId: " + input.agentRunId()); + } + + LOG.infof("[AgentRun:%s][ToolCall:%s] Executing tool method: %s", + input.agentRunId(), input.toolCallId(), pendingCall.method().getName()); + + // Set the flag so the CDI interceptor passes through on this thread. + DaprToolCallInterceptor.IS_ACTIVITY_CALL.set(Boolean.TRUE); + try { + // Invoke the @Tool method via the CDI proxy. + // The CDI interceptor will fire again but pass through because IS_ACTIVITY_CALL is set. + Object result = pendingCall.method().invoke(pendingCall.target(), pendingCall.args()); + String resultStr = String.valueOf(result); + runCtx.completeCall(input.toolCallId(), result); + LOG.infof("[AgentRun:%s][ToolCall:%s] Tool method completed: %s → %s", + input.agentRunId(), input.toolCallId(), pendingCall.method().getName(), resultStr); + return new ToolCallOutput(input.toolName(), input.args(), resultStr); + } catch (InvocationTargetException ite) { + Throwable cause = ite.getCause() != null ? ite.getCause() : ite; + LOG.errorf("[AgentRun:%s][ToolCall:%s] Tool method failed: %s — %s", + input.agentRunId(), input.toolCallId(), pendingCall.method().getName(), cause.getMessage()); + runCtx.failCall(input.toolCallId(), cause); + throw new RuntimeException("Tool execution failed: " + pendingCall.method().getName(), cause); + } catch (Exception e) { + LOG.errorf("[AgentRun:%s][ToolCall:%s] Tool method failed: %s — %s", + input.agentRunId(), input.toolCallId(), pendingCall.method().getName(), e.getMessage()); + runCtx.failCall(input.toolCallId(), e); + throw new RuntimeException("Tool execution failed: " + pendingCall.method().getName(), e); + } finally { + DaprToolCallInterceptor.IS_ACTIVITY_CALL.remove(); + } + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/ToolCallInput.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/ToolCallInput.java new file mode 100644 index 0000000000..9e0735d62a --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/ToolCallInput.java @@ -0,0 +1,28 @@ +/* + * 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.quarkiverse.dapr.langchain4j.agent.activities; + +/** + * Input record for {@link ToolCallActivity}. + * + * @param agentRunId the agent run ID used to look up the + * {@link io.quarkiverse.dapr.langchain4j.agent.AgentRunContext} + * @param toolCallId the unique tool call ID used to look up the pending + * {@link io.quarkiverse.dapr.langchain4j.agent.AgentRunContext.PendingCall} + * @param toolName name of the {@code @Tool}-annotated method being executed; stored in the + * Dapr activity input for observability in the workflow history + * @param args string representation of the arguments passed to the tool method + */ +public record ToolCallInput(String agentRunId, String toolCallId, String toolName, String args) { +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/ToolCallOutput.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/ToolCallOutput.java new file mode 100644 index 0000000000..649bca6f22 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/ToolCallOutput.java @@ -0,0 +1,26 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.quarkiverse.dapr.langchain4j.agent.activities; + +/** + * Output record returned by {@link ToolCallActivity} after a {@code @Tool}-annotated + * method has been executed. Stored in the Dapr workflow history so callers can + * inspect what each tool call produced. + * + * @param toolName name of the {@code @Tool} method that was invoked + * @param args string representation of the arguments that were passed to the tool + * @param result string representation of the value returned by the tool method + */ +public record ToolCallOutput(String toolName, String args, String result) { +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/workflow/AgentEvent.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/workflow/AgentEvent.java new file mode 100644 index 0000000000..49e3d49bc8 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/workflow/AgentEvent.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.quarkiverse.dapr.langchain4j.agent.workflow; + +/** + * External event sent to {@link AgentRunWorkflow} via {@code DaprWorkflowClient.raiseEvent()}. + * + *

Two event types are used: + *

    + *
  • {@code "tool-call"} — a {@code @Tool}-annotated method was intercepted; the workflow + * should schedule a {@link io.quarkiverse.dapr.langchain4j.agent.activities.ToolCallActivity}.
  • + *
  • {@code "done"} — the agent has finished executing; the workflow should terminate.
  • + *
+ * + * @param type event discriminator: {@code "tool-call"} or {@code "done"} + * @param toolCallId unique ID for this tool call (null for "done" events) + * @param toolName name of the tool method being called (null for "done" events) + * @param args serialized arguments (reserved for future use; null for now) + */ +public record AgentEvent( + String type, + String toolCallId, + String toolName, + String args) { +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/workflow/AgentRunInput.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/workflow/AgentRunInput.java new file mode 100644 index 0000000000..9e9b043138 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/workflow/AgentRunInput.java @@ -0,0 +1,31 @@ +/* + * 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.quarkiverse.dapr.langchain4j.agent.workflow; + +/** + * Input record for {@link AgentRunWorkflow}. + * + * @param agentRunId unique ID correlating the Dapr Workflow instance to its in-memory + * {@link io.quarkiverse.dapr.langchain4j.agent.AgentRunContext} + * @param agentName human-readable name from {@code @Agent(name)} (or class+method for CDI + * beans), used for observability in the Dapr workflow history + * @param userMessage the {@code @UserMessage} template text (CDI bean path) or the first + * rendered user message from the {@code ChatRequest} (AiService path); + * may be {@code null} when started by an orchestration activity + * @param systemMessage the {@code @SystemMessage} template text (CDI bean path) or the + * rendered system message from the {@code ChatRequest} (AiService path); + * may be {@code null} + */ +public record AgentRunInput(String agentRunId, String agentName, String userMessage, String systemMessage) { +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/workflow/AgentRunOutput.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/workflow/AgentRunOutput.java new file mode 100644 index 0000000000..75a895d88d --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/workflow/AgentRunOutput.java @@ -0,0 +1,47 @@ +/* + * 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.quarkiverse.dapr.langchain4j.agent.workflow; + +import io.quarkiverse.dapr.langchain4j.agent.activities.LlmCallOutput; +import io.quarkiverse.dapr.langchain4j.agent.activities.ToolCallOutput; + +import java.util.Collections; +import java.util.List; + +/** + * Aggregated output of a completed {@link AgentRunWorkflow}. Set as the Dapr + * workflow custom status after every activity so observers can follow execution + * progress in real time, and reflects the final state once {@code "done"} is received. + * + * @param agentName human-readable name of the {@code @Agent} that was executed + * @param toolCalls ordered list of tool calls made by the agent, each with its + * input arguments and return value + * @param llmCalls ordered list of LLM calls made by the agent, each with the + * model method name and the response text + */ +public record AgentRunOutput( + String agentName, + List toolCalls, + List llmCalls) { + + /** + * Creates an AgentRunOutput with unmodifiable defensive copies of the lists. + */ + public AgentRunOutput(String agentName, List toolCalls, + List llmCalls) { + this.agentName = agentName; + this.toolCalls = toolCalls == null ? null : Collections.unmodifiableList(List.copyOf(toolCalls)); + this.llmCalls = llmCalls == null ? null : Collections.unmodifiableList(List.copyOf(llmCalls)); + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/workflow/AgentRunWorkflow.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/workflow/AgentRunWorkflow.java new file mode 100644 index 0000000000..e7c43109aa --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/workflow/AgentRunWorkflow.java @@ -0,0 +1,126 @@ +/* + * 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.quarkiverse.dapr.langchain4j.agent.workflow; + +import io.dapr.workflows.Workflow; +import io.dapr.workflows.WorkflowStub; +import io.quarkiverse.dapr.langchain4j.agent.activities.LlmCallActivity; +import io.quarkiverse.dapr.langchain4j.agent.activities.LlmCallInput; +import io.quarkiverse.dapr.langchain4j.agent.activities.LlmCallOutput; +import io.quarkiverse.dapr.langchain4j.agent.activities.ToolCallActivity; +import io.quarkiverse.dapr.langchain4j.agent.activities.ToolCallInput; +import io.quarkiverse.dapr.langchain4j.agent.activities.ToolCallOutput; +import io.quarkiverse.dapr.workflows.WorkflowMetadata; +import jakarta.enterprise.context.ApplicationScoped; +import org.jboss.logging.Logger; + +import java.util.ArrayList; +import java.util.List; +/** + * Dapr Workflow representing the execution of a single {@code @Agent}-annotated method, + * including all tool and LLM calls the agent makes during its ReAct loop. + * + *

Lifecycle

+ *
    + *
  1. Started by {@link io.quarkiverse.dapr.langchain4j.workflow.orchestration.activities.AgentExecutionActivity} + * (orchestration path) or lazily by {@link io.quarkiverse.dapr.langchain4j.agent.AgentRunLifecycleManager} + * (standalone {@code @Agent} path) just before the agent is submitted.
  2. + *
  3. Loops waiting for {@code "agent-event"} external events raised by + * {@link io.quarkiverse.dapr.langchain4j.agent.DaprToolCallInterceptor} and + * {@link io.quarkiverse.dapr.langchain4j.agent.DaprLlmCallInterceptor}.
  4. + *
  5. For each {@code "tool-call"} event, schedules a {@link ToolCallActivity} that + * executes the tool on the Dapr activity thread and returns a {@link ToolCallOutput}.
  6. + *
  7. For each {@code "llm-call"} event, schedules a {@link LlmCallActivity} that + * executes the LLM call on the Dapr activity thread and returns a {@link LlmCallOutput}.
  8. + *
  9. After each activity, updates the Dapr custom status with an {@link AgentRunOutput} + * snapshot so observers can follow execution progress in real time.
  10. + *
  11. Terminates when a {@code "done"} event is received, setting the final + * {@link AgentRunOutput} as the custom status.
  12. + *
+ */ + +@ApplicationScoped +@WorkflowMetadata(name = "agent") +public class AgentRunWorkflow implements Workflow { + + private static final Logger LOG = Logger.getLogger(AgentRunWorkflow.class); + + @Override + public WorkflowStub create() { + return ctx -> { + AgentRunInput input = ctx.getInput(AgentRunInput.class); + String agentRunId = input.agentRunId(); + String agentName = input.agentName(); + + LOG.infof("[AgentRun:%s] AgentRunWorkflow started — agent=%s, userMessage=%s, systemMessage=%s", + agentRunId, agentName, + truncate(input.userMessage(), 120), + truncate(input.systemMessage(), 120)); + + List toolCallOutputs = new ArrayList<>(); + List llmCallOutputs = new ArrayList<>(); + + while (true) { + // Wait for the next event from the agent thread or completion signal. + AgentEvent event = ctx.waitForExternalEvent("agent-event", AgentEvent.class).await(); + + LOG.infof("[AgentRun:%s] Received event: type=%s, callId=%s, name=%s", + agentRunId, event.type(), event.toolCallId(), event.toolName()); + + if ("done".equals(event.type())) { + LOG.infof("[AgentRun:%s] AgentRunWorkflow completed — agent=%s, toolCalls=%d, llmCalls=%d", + agentRunId, agentName, toolCallOutputs.size(), llmCallOutputs.size()); + break; + } + + if ("tool-call".equals(event.type())) { + LOG.infof("[AgentRun:%s] Scheduling ToolCallActivity — tool=%s, args=%s", + agentRunId, event.toolName(), event.args()); + ToolCallOutput toolOutput = ctx.callActivity( + "tool-call", + new ToolCallInput(agentRunId, event.toolCallId(), event.toolName(), event.args()), + ToolCallOutput.class).await(); + toolCallOutputs.add(toolOutput); + LOG.infof("[AgentRun:%s] ToolCallActivity completed — tool=%s → %s", + agentRunId, event.toolName(), toolOutput.result()); + ctx.setCustomStatus(new AgentRunOutput(agentName, toolCallOutputs, llmCallOutputs)); + } + + if ("llm-call".equals(event.type())) { + LOG.infof("[AgentRun:%s] Scheduling LlmCallActivity — method=%s", + agentRunId, event.toolName()); + LlmCallOutput llmOutput = ctx.callActivity( + "llm-call", + new LlmCallInput(agentRunId, event.toolCallId(), event.toolName(), event.args()), + LlmCallOutput.class).await(); + llmCallOutputs.add(llmOutput); + LOG.infof("[AgentRun:%s] LlmCallActivity completed — method=%s, response=%s", + agentRunId, event.toolName(), llmOutput.response()); + ctx.setCustomStatus(new AgentRunOutput(agentName, toolCallOutputs, llmCallOutputs)); + } + } + + // Set the final output so it is visible in the Dapr workflow dashboard. + ctx.setCustomStatus(new AgentRunOutput(agentName, toolCallOutputs, llmCallOutputs)); + }; + } + + private static String truncate(String s, int maxLength) { + if (s == null) { + return null; + } + String trimmed = s.strip(); + return trimmed.length() <= maxLength ? trimmed : trimmed.substring(0, maxLength) + "…"; + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/memory/KeyValueChatMemoryStore.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/memory/KeyValueChatMemoryStore.java new file mode 100644 index 0000000000..9a5f81b917 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/memory/KeyValueChatMemoryStore.java @@ -0,0 +1,91 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.quarkiverse.dapr.langchain4j.memory; + +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.ChatMessageDeserializer; +import dev.langchain4j.data.message.ChatMessageSerializer; +import dev.langchain4j.store.memory.chat.ChatMemoryStore; +import io.dapr.client.DaprClient; +import io.dapr.client.domain.State; + +import java.util.Collections; +import java.util.List; +import java.util.function.Function; + +/** + * A {@link ChatMemoryStore} backed by Dapr's key-value state store. + * + *

Messages are serialized to JSON using {@link ChatMessageSerializer} and stored + * under the key {@code memoryId.toString()} in the configured Dapr state store. + */ +public class KeyValueChatMemoryStore implements ChatMemoryStore { + + private final DaprClient daprClient; + private final String stateStoreName; + private final Function, String> serializer; + private final Function> deserializer; + + /** + * Creates a new KeyValueChatMemoryStore with default serializers. + * + * @param daprClient the Dapr client + * @param stateStoreName the state store name + */ + public KeyValueChatMemoryStore(DaprClient daprClient, String stateStoreName) { + this(daprClient, stateStoreName, + ChatMessageSerializer::messagesToJson, + ChatMessageDeserializer::messagesFromJson); + } + + /** + * Creates a new KeyValueChatMemoryStore with custom serializers. + * + * @param daprClient the Dapr client + * @param stateStoreName the state store name + * @param serializer the message serializer + * @param deserializer the message deserializer + */ + KeyValueChatMemoryStore(DaprClient daprClient, String stateStoreName, + Function, String> serializer, + Function> deserializer) { + this.daprClient = daprClient; + this.stateStoreName = stateStoreName; + this.serializer = serializer; + this.deserializer = deserializer; + } + + @Override + public List getMessages(Object memoryId) { + String key = memoryId.toString(); + State state = daprClient.getState(stateStoreName, key, String.class).block(); + if (state == null || state.getValue() == null || state.getValue().isEmpty()) { + return Collections.emptyList(); + } + return deserializer.apply(state.getValue()); + } + + @Override + public void updateMessages(Object memoryId, List messages) { + String key = memoryId.toString(); + String json = serializer.apply(messages); + daprClient.saveState(stateStoreName, key, json).block(); + } + + @Override + public void deleteMessages(Object memoryId) { + String key = memoryId.toString(); + daprClient.deleteState(stateStoreName, key).block(); + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprAgentService.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprAgentService.java new file mode 100644 index 0000000000..c9eab1345d --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprAgentService.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.quarkiverse.dapr.langchain4j.workflow; + +/** + * Marker interface for Dapr-backed agent service implementations. + * Provides the Dapr Workflow type name used to schedule the orchestration. + */ +public interface DaprAgentService { + + /** + * Returns the simple class name of the Dapr Workflow to schedule + * for this orchestration pattern. + * + * @return the workflow type name + */ + String workflowType(); +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprAgentServiceUtil.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprAgentServiceUtil.java new file mode 100644 index 0000000000..2900ee51e5 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprAgentServiceUtil.java @@ -0,0 +1,38 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.quarkiverse.dapr.langchain4j.workflow; + +/** + * Shared utility methods for Dapr agent services. + */ +public final class DaprAgentServiceUtil { + + private DaprAgentServiceUtil() { + } + + /** + * Sanitizes a name for use as a Dapr workflow identifier. + * Replaces any non-alphanumeric characters (except hyphens and underscores) + * with underscores. + * + * @param name the name to sanitize + * @return the sanitized name + */ + public static String safeName(String name) { + if (name == null || name.isEmpty()) { + return "unnamed"; + } + return name.replaceAll("[^a-zA-Z0-9_-]", "_"); + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprConditionalAgentService.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprConditionalAgentService.java new file mode 100644 index 0000000000..dce56702b4 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprConditionalAgentService.java @@ -0,0 +1,155 @@ +/* + * 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.quarkiverse.dapr.langchain4j.workflow; + +import dev.langchain4j.agentic.UntypedAgent; +import dev.langchain4j.agentic.declarative.ConditionalAgent; +import dev.langchain4j.agentic.internal.AgentExecutor; +import dev.langchain4j.agentic.internal.AgentUtil; +import dev.langchain4j.agentic.planner.AgenticSystemTopology; +import dev.langchain4j.agentic.scope.AgenticScope; +import dev.langchain4j.agentic.workflow.impl.ConditionalAgentServiceImpl; +import io.dapr.workflows.client.DaprWorkflowClient; +import io.quarkiverse.dapr.langchain4j.workflow.orchestration.ConditionalOrchestrationWorkflow; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Predicate; + +/** + * Conditional agent service backed by a Dapr Workflow. + * Extends {@link ConditionalAgentServiceImpl} and implements {@link DaprAgentService} + * to provide Dapr-based conditional orchestration. + */ +public class DaprConditionalAgentService extends ConditionalAgentServiceImpl implements DaprAgentService { + + private final DaprWorkflowClient workflowClient; + private final Map> daprConditions = new HashMap<>(); + private int agentCounter = 0; + + /** + * Creates a new conditional agent service for the given agent service class. + * + * @param agentServiceClass the agent service class to create the service for + * @param workflowClient the Dapr workflow client used for orchestration + */ + public DaprConditionalAgentService(Class agentServiceClass, DaprWorkflowClient workflowClient) { + super(agentServiceClass, resolveMethod(agentServiceClass)); + this.workflowClient = workflowClient; + } + + private static java.lang.reflect.Method resolveMethod(Class agentServiceClass) { + if (agentServiceClass == UntypedAgent.class) { + return null; + } + return AgentUtil.validateAgentClass(agentServiceClass, false, ConditionalAgent.class); + } + + /** + * {@inheritDoc} + */ + @Override + public String workflowType() { + return ConditionalOrchestrationWorkflow.class.getCanonicalName(); + } + + /** + * {@inheritDoc} + */ + @Override + public DaprConditionalAgentService subAgents(Predicate condition, Object... agents) { + for (int i = 0; i < agents.length; i++) { + daprConditions.put(agentCounter + i, condition); + } + agentCounter += agents.length; + super.subAgents(condition, agents); + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public DaprConditionalAgentService subAgents(String conditionDescription, + Predicate condition, Object... agents) { + for (int i = 0; i < agents.length; i++) { + daprConditions.put(agentCounter + i, condition); + } + agentCounter += agents.length; + super.subAgents(conditionDescription, condition, agents); + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public DaprConditionalAgentService subAgent(Predicate condition, AgentExecutor agent) { + daprConditions.put(agentCounter, condition); + agentCounter++; + super.subAgent(condition, agent); + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public DaprConditionalAgentService subAgent(String conditionDescription, + Predicate condition, AgentExecutor agent) { + daprConditions.put(agentCounter, condition); + agentCounter++; + super.subAgent(conditionDescription, condition, agent); + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public T build() { + return build(() -> { + DaprWorkflowPlanner planner = new DaprWorkflowPlanner( + ConditionalOrchestrationWorkflow.class, + "Conditional", + AgenticSystemTopology.ROUTER, + workflowClient); + planner.setConditions(daprConditions); + return planner; + }); + } + + /** + * Creates a builder for an untyped conditional agent service. + * + * @param workflowClient the Dapr workflow client used for orchestration + * @return a new untyped conditional agent service builder + */ + public static DaprConditionalAgentService builder(DaprWorkflowClient workflowClient) { + return new DaprConditionalAgentService<>(UntypedAgent.class, workflowClient); + } + + /** + * Creates a builder for a typed conditional agent service. + * + * @param the agent service type + * @param agentServiceClass the agent service class to create the builder for + * @param workflowClient the Dapr workflow client used for orchestration + * @return a new typed conditional agent service builder + */ + public static DaprConditionalAgentService builder(Class agentServiceClass, + DaprWorkflowClient workflowClient) { + return new DaprConditionalAgentService<>(agentServiceClass, workflowClient); + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprLoopAgentService.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprLoopAgentService.java new file mode 100644 index 0000000000..17446787e9 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprLoopAgentService.java @@ -0,0 +1,158 @@ +/* + * 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.quarkiverse.dapr.langchain4j.workflow; + +import dev.langchain4j.agentic.UntypedAgent; +import dev.langchain4j.agentic.declarative.LoopAgent; +import dev.langchain4j.agentic.internal.AgentUtil; +import dev.langchain4j.agentic.planner.AgenticSystemTopology; +import dev.langchain4j.agentic.scope.AgenticScope; +import dev.langchain4j.agentic.workflow.impl.LoopAgentServiceImpl; +import io.dapr.workflows.client.DaprWorkflowClient; +import io.quarkiverse.dapr.langchain4j.workflow.orchestration.LoopOrchestrationWorkflow; + +import java.lang.reflect.Method; +import java.util.function.BiPredicate; +import java.util.function.Predicate; + +/** + * Loop agent service backed by a Dapr Workflow. + * Extends {@link LoopAgentServiceImpl} and implements {@link DaprAgentService} + * to provide Dapr-based loop orchestration with configurable exit conditions. + */ +public class DaprLoopAgentService extends LoopAgentServiceImpl implements DaprAgentService { + + private final DaprWorkflowClient workflowClient; + private int daprMaxIterations = Integer.MAX_VALUE; + private BiPredicate daprExitCondition; + private boolean daprTestExitAtLoopEnd; + + /** + * Constructs a new DaprLoopAgentService. + * + * @param agentServiceClass the agent service class + * @param workflowClient the Dapr workflow client + */ + public DaprLoopAgentService(Class agentServiceClass, DaprWorkflowClient workflowClient) { + super(agentServiceClass, resolveMethod(agentServiceClass)); + this.workflowClient = workflowClient; + } + + private static Method resolveMethod(Class agentServiceClass) { + if (agentServiceClass == UntypedAgent.class) { + return null; + } + return AgentUtil.validateAgentClass(agentServiceClass, false, LoopAgent.class); + } + + @Override + public String workflowType() { + return LoopOrchestrationWorkflow.class.getCanonicalName(); + } + + @Override + public DaprLoopAgentService maxIterations(int maxIterations) { + this.daprMaxIterations = maxIterations; + super.maxIterations(maxIterations); + return this; + } + + @Override + public DaprLoopAgentService exitCondition(Predicate exitCondition) { + this.daprExitCondition = (scope, iter) -> exitCondition.test(scope); + super.exitCondition(exitCondition); + return this; + } + + @Override + public DaprLoopAgentService exitCondition(BiPredicate exitCondition) { + this.daprExitCondition = exitCondition; + super.exitCondition(exitCondition); + return this; + } + + /** + * Sets the exit condition with a description. + * + * @param description the description + * @param exitCondition the exit condition predicate + * @return this builder + */ + @Override + public DaprLoopAgentService exitCondition(String description, Predicate exitCondition) { + this.daprExitCondition = (scope, iter) -> exitCondition.test(scope); + super.exitCondition(description, exitCondition); + return this; + } + + /** + * Sets the exit condition with a description and iteration count. + * + * @param description the description + * @param exitCondition the exit condition bi-predicate + * @return this builder + */ + @Override + public DaprLoopAgentService exitCondition( + String description, BiPredicate exitCondition) { + this.daprExitCondition = exitCondition; + super.exitCondition(description, exitCondition); + return this; + } + + @Override + public DaprLoopAgentService testExitAtLoopEnd(boolean testExitAtLoopEnd) { + this.daprTestExitAtLoopEnd = testExitAtLoopEnd; + super.testExitAtLoopEnd(testExitAtLoopEnd); + return this; + } + + @Override + public T build() { + return build(() -> { + DaprWorkflowPlanner planner = new DaprWorkflowPlanner( + LoopOrchestrationWorkflow.class, + "Loop", + AgenticSystemTopology.LOOP, + workflowClient); + planner.setMaxIterations(daprMaxIterations); + planner.setExitCondition(daprExitCondition); + planner.setTestExitAtLoopEnd(daprTestExitAtLoopEnd); + return planner; + }); + } + + /** + * Creates a builder for an untyped agent. + * + * @param workflowClient the Dapr workflow client + * @return a new builder instance + */ + public static DaprLoopAgentService builder(DaprWorkflowClient workflowClient) { + return new DaprLoopAgentService<>(UntypedAgent.class, workflowClient); + } + + /** + * Creates a builder for a typed agent service. + * + * @param agentServiceClass the agent service class + * @param workflowClient the Dapr workflow client + * @param the agent service type + * @return a new builder instance + */ + public static DaprLoopAgentService builder(Class agentServiceClass, + DaprWorkflowClient workflowClient) { + return new DaprLoopAgentService<>(agentServiceClass, workflowClient); + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprParallelAgentService.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprParallelAgentService.java new file mode 100644 index 0000000000..3fa2d428e0 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprParallelAgentService.java @@ -0,0 +1,88 @@ +/* + * 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.quarkiverse.dapr.langchain4j.workflow; + +import dev.langchain4j.agentic.UntypedAgent; +import dev.langchain4j.agentic.declarative.ParallelAgent; +import dev.langchain4j.agentic.internal.AgentUtil; +import dev.langchain4j.agentic.planner.AgenticSystemTopology; +import dev.langchain4j.agentic.workflow.ParallelAgentService; +import dev.langchain4j.agentic.workflow.impl.ParallelAgentServiceImpl; +import io.dapr.workflows.client.DaprWorkflowClient; +import io.quarkiverse.dapr.langchain4j.workflow.orchestration.ParallelOrchestrationWorkflow; + +/** + * Parallel agent service backed by a Dapr Workflow. + * Extends {@link ParallelAgentServiceImpl} and implements {@link DaprAgentService} + * to provide Dapr-based parallel orchestration. + */ +public class DaprParallelAgentService extends ParallelAgentServiceImpl implements DaprAgentService { + + private final DaprWorkflowClient workflowClient; + + /** + * Creates a new DaprParallelAgentService. + * + * @param agentServiceClass the agent service class + * @param workflowClient the Dapr workflow client + */ + public DaprParallelAgentService(Class agentServiceClass, DaprWorkflowClient workflowClient) { + super(agentServiceClass, resolveMethod(agentServiceClass)); + this.workflowClient = workflowClient; + } + + private static java.lang.reflect.Method resolveMethod(Class agentServiceClass) { + if (agentServiceClass == UntypedAgent.class) { + return null; + } + return AgentUtil.validateAgentClass(agentServiceClass, false, ParallelAgent.class); + } + + @Override + public String workflowType() { + return ParallelOrchestrationWorkflow.class.getCanonicalName(); + } + + @Override + public T build() { + return build(() -> new DaprWorkflowPlanner( + ParallelOrchestrationWorkflow.class, + "Parallel", + AgenticSystemTopology.PARALLEL, + workflowClient)); + } + + /** + * Creates a builder for untyped agents. + * + * @param workflowClient the Dapr workflow client + * @return a new DaprParallelAgentService instance + */ + public static DaprParallelAgentService builder(DaprWorkflowClient workflowClient) { + return new DaprParallelAgentService<>(UntypedAgent.class, workflowClient); + } + + /** + * Creates a builder for typed agents. + * + * @param agentServiceClass the agent service class + * @param workflowClient the Dapr workflow client + * @param the agent service type + * @return a new DaprParallelAgentService instance + */ + public static DaprParallelAgentService builder(Class agentServiceClass, + DaprWorkflowClient workflowClient) { + return new DaprParallelAgentService<>(agentServiceClass, workflowClient); + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprPlannerRegistry.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprPlannerRegistry.java new file mode 100644 index 0000000000..a2ac21228a --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprPlannerRegistry.java @@ -0,0 +1,64 @@ +/* + * 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.quarkiverse.dapr.langchain4j.workflow; + +import java.util.concurrent.ConcurrentHashMap; + +/** + * Static registry mapping planner IDs to {@link DaprWorkflowPlanner} instances. + * Allows Dapr WorkflowActivities (which are instantiated by the Dapr SDK) to + * look up the in-process planner. + */ +public class DaprPlannerRegistry { + + private static final ConcurrentHashMap registry = new ConcurrentHashMap<>(); + + /** + * Registers a planner with the given ID. + * + * @param id the planner ID + * @param planner the planner instance to register + */ + public static void register(String id, DaprWorkflowPlanner planner) { + registry.put(id, planner); + } + + /** + * Returns the planner for the given ID. + * + * @param id the planner ID + * @return the planner instance, or {@code null} if not registered + */ + public static DaprWorkflowPlanner get(String id) { + return registry.get(id); + } + + /** + * Unregisters the planner for the given ID. + * + * @param id the planner ID to unregister + */ + public static void unregister(String id) { + registry.remove(id); + } + + /** + * Returns the set of all registered planner IDs as a string. + * + * @return a string representation of the registered planner IDs + */ + public static String getRegisteredIds() { + return registry.keySet().toString(); + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprSequentialAgentService.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprSequentialAgentService.java new file mode 100644 index 0000000000..545c0c7daf --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprSequentialAgentService.java @@ -0,0 +1,88 @@ +/* + * 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.quarkiverse.dapr.langchain4j.workflow; + +import dev.langchain4j.agentic.UntypedAgent; +import dev.langchain4j.agentic.declarative.SequenceAgent; +import dev.langchain4j.agentic.internal.AgentUtil; +import dev.langchain4j.agentic.planner.AgenticSystemTopology; +import dev.langchain4j.agentic.workflow.SequentialAgentService; +import dev.langchain4j.agentic.workflow.impl.SequentialAgentServiceImpl; +import io.dapr.workflows.client.DaprWorkflowClient; +import io.quarkiverse.dapr.langchain4j.workflow.orchestration.SequentialOrchestrationWorkflow; + +/** + * Sequential agent service backed by a Dapr Workflow. + * Extends {@link SequentialAgentServiceImpl} and implements {@link DaprAgentService} + * to provide Dapr-based sequential orchestration. + */ +public class DaprSequentialAgentService extends SequentialAgentServiceImpl implements DaprAgentService { + + private final DaprWorkflowClient workflowClient; + + /** + * Creates a new DaprSequentialAgentService. + * + * @param agentServiceClass the agent service class + * @param workflowClient the Dapr workflow client + */ + public DaprSequentialAgentService(Class agentServiceClass, DaprWorkflowClient workflowClient) { + super(agentServiceClass, resolveMethod(agentServiceClass)); + this.workflowClient = workflowClient; + } + + private static java.lang.reflect.Method resolveMethod(Class agentServiceClass) { + if (agentServiceClass == UntypedAgent.class) { + return null; + } + return AgentUtil.validateAgentClass(agentServiceClass, false, SequenceAgent.class); + } + + @Override + public String workflowType() { + return SequentialOrchestrationWorkflow.class.getCanonicalName(); + } + + @Override + public T build() { + return build(() -> new DaprWorkflowPlanner( + SequentialOrchestrationWorkflow.class, + "Sequential", + AgenticSystemTopology.SEQUENCE, + workflowClient)); + } + + /** + * Creates a builder for untyped agents. + * + * @param workflowClient the Dapr workflow client + * @return a new DaprSequentialAgentService instance + */ + public static DaprSequentialAgentService builder(DaprWorkflowClient workflowClient) { + return new DaprSequentialAgentService<>(UntypedAgent.class, workflowClient); + } + + /** + * Creates a builder for typed agents. + * + * @param agentServiceClass the agent service class + * @param workflowClient the Dapr workflow client + * @param the agent service type + * @return a new DaprSequentialAgentService instance + */ + public static DaprSequentialAgentService builder(Class agentServiceClass, + DaprWorkflowClient workflowClient) { + return new DaprSequentialAgentService<>(agentServiceClass, workflowClient); + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprWorkflowAgentsBuilder.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprWorkflowAgentsBuilder.java new file mode 100644 index 0000000000..6309bbd3ed --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprWorkflowAgentsBuilder.java @@ -0,0 +1,82 @@ +/* + * 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.quarkiverse.dapr.langchain4j.workflow; + +import dev.langchain4j.agentic.UntypedAgent; +import dev.langchain4j.agentic.workflow.ConditionalAgentService; +import dev.langchain4j.agentic.workflow.LoopAgentService; +import dev.langchain4j.agentic.workflow.ParallelAgentService; +import dev.langchain4j.agentic.workflow.SequentialAgentService; +import dev.langchain4j.agentic.workflow.WorkflowAgentsBuilder; +import io.dapr.workflows.client.DaprWorkflowClient; +import jakarta.enterprise.inject.spi.CDI; + +/** + * Dapr Workflow-backed implementation of {@link WorkflowAgentsBuilder}. + * Discovered via Java SPI to provide Dapr-based agent service builders + * for {@code @SequenceAgent}, {@code @ParallelAgent}, etc. + * + *

Obtains the {@link DaprWorkflowClient} from CDI to pass to each builder. + */ +public class DaprWorkflowAgentsBuilder implements WorkflowAgentsBuilder { + + /** + * Retrieves the DaprWorkflowClient from CDI. + * + * @return the workflow client + */ + private DaprWorkflowClient getWorkflowClient() { + return CDI.current().select(DaprWorkflowClient.class).get(); + } + + @Override + public SequentialAgentService sequenceBuilder() { + return DaprSequentialAgentService.builder(getWorkflowClient()); + } + + @Override + public SequentialAgentService sequenceBuilder(Class agentServiceClass) { + return DaprSequentialAgentService.builder(agentServiceClass, getWorkflowClient()); + } + + @Override + public ParallelAgentService parallelBuilder() { + return DaprParallelAgentService.builder(getWorkflowClient()); + } + + @Override + public ParallelAgentService parallelBuilder(Class agentServiceClass) { + return DaprParallelAgentService.builder(agentServiceClass, getWorkflowClient()); + } + + @Override + public LoopAgentService loopBuilder() { + return DaprLoopAgentService.builder(getWorkflowClient()); + } + + @Override + public LoopAgentService loopBuilder(Class agentServiceClass) { + return DaprLoopAgentService.builder(agentServiceClass, getWorkflowClient()); + } + + @Override + public ConditionalAgentService conditionalBuilder() { + return DaprConditionalAgentService.builder(getWorkflowClient()); + } + + @Override + public ConditionalAgentService conditionalBuilder(Class agentServiceClass) { + return DaprConditionalAgentService.builder(agentServiceClass, getWorkflowClient()); + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprWorkflowPlanner.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprWorkflowPlanner.java new file mode 100644 index 0000000000..fc1b1a47fe --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprWorkflowPlanner.java @@ -0,0 +1,448 @@ +/* + * 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.quarkiverse.dapr.langchain4j.workflow; + +import dev.langchain4j.agentic.Agent; +import dev.langchain4j.agentic.planner.Action; +import dev.langchain4j.agentic.planner.AgentInstance; +import dev.langchain4j.agentic.planner.AgenticSystemTopology; +import dev.langchain4j.agentic.planner.InitPlanningContext; +import dev.langchain4j.agentic.planner.Planner; +import dev.langchain4j.agentic.planner.PlanningContext; +import dev.langchain4j.agentic.scope.AgenticScope; +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import io.dapr.workflows.Workflow; +import io.dapr.workflows.client.DaprWorkflowClient; +import io.quarkiverse.dapr.langchain4j.agent.DaprAgentContextHolder; +import io.quarkiverse.dapr.langchain4j.agent.DaprAgentRunRegistry; +import io.quarkiverse.dapr.langchain4j.agent.workflow.AgentEvent; +import io.quarkiverse.dapr.langchain4j.workflow.orchestration.OrchestrationInput; +import org.jboss.logging.Logger; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.BiPredicate; +import java.util.function.Predicate; + +/** + * Core planner that bridges Langchain4j's agentic {@link Planner} framework with + * Dapr Workflows. Uses a lockstep synchronization pattern (BlockingQueue + CompletableFuture) + * to coordinate between Dapr Workflow execution and Langchain4j's agent planning loop. + */ +public class DaprWorkflowPlanner implements Planner { + + private static final Logger LOG = Logger.getLogger(DaprWorkflowPlanner.class); + + /** + * Metadata extracted from an {@link AgentInstance} for propagation to + * the per-agent {@link io.quarkiverse.dapr.langchain4j.agent.workflow.AgentRunWorkflow}. + * + * @param agentName human-readable name from {@code @Agent(name)} or the instance name + * @param userMessage the {@code @UserMessage} template text, or {@code null} if not annotated + * @param systemMessage the {@code @SystemMessage} template text, or {@code null} if not annotated + */ + public record AgentMetadata(String agentName, String userMessage, String systemMessage) { + } + + /** + * Exchange record used for thread synchronization between the Dapr Workflow + * thread (via activities) and the Langchain4j planner thread. + * A null agent signals workflow completion (sentinel). + * The {@code agentRunId} is forwarded to the planner so it can set + * {@link DaprAgentContextHolder} on the executing thread before tool calls begin. + */ + @SuppressWarnings({"EI_EXPOSE_REP", "EI_EXPOSE_REP2"}) + public record AgentExchange(AgentInstance agent, CompletableFuture continuation, String agentRunId) { + } + + /** + * Tracks per-agent completion info so {@link #nextAction} can signal the + * orchestration workflow and clean up after each agent finishes. + */ + private record PendingAgentInfo(String agentRunId) { + } + + private final String plannerId; + private final Class workflowClass; + private final String description; + private final AgenticSystemTopology topology; + private final DaprWorkflowClient workflowClient; + + private final BlockingQueue agentExchangeQueue = new LinkedBlockingQueue<>(); + private final ReentrantLock batchLock = new ReentrantLock(); + private volatile boolean workflowDone = false; + + private List agents = Collections.emptyList(); + private AgenticScope agenticScope; + + // Loop configuration + private int maxIterations = Integer.MAX_VALUE; + private BiPredicate exitCondition; + private boolean testExitAtLoopEnd; + + // Conditional configuration + private Map> conditions = Collections.emptyMap(); + + // Thread-safe deque for parallel agent futures — nextAction() is called from + // different threads (one per agent) in LangChain4j's parallel executor. + private final ConcurrentLinkedDeque> pendingFutures = new ConcurrentLinkedDeque<>(); + + // Thread-safe deque for per-agent completion info — polled in nextAction() + // alongside pendingFutures to signal the orchestration workflow and clean up. + private final ConcurrentLinkedDeque pendingAgentInfos = new ConcurrentLinkedDeque<>(); + + /** + * Creates a new DaprWorkflowPlanner. + * + * @param workflowClass the Dapr workflow class to schedule + * @param description a human-readable description + * @param topology the agentic system topology + * @param workflowClient the Dapr workflow client + */ + public DaprWorkflowPlanner(Class workflowClass, String description, + AgenticSystemTopology topology, DaprWorkflowClient workflowClient) { + this.plannerId = UUID.randomUUID().toString(); + this.workflowClass = workflowClass; + this.description = description; + this.topology = topology; + this.workflowClient = workflowClient; + } + + @Override + public AgenticSystemTopology topology() { + return topology; + } + + @Override + public void init(InitPlanningContext initPlanningContext) { + this.agents = new ArrayList<>(initPlanningContext.subagents()); + this.agenticScope = initPlanningContext.agenticScope(); + DaprPlannerRegistry.register(plannerId, this); + } + + @Override + public Action firstAction(PlanningContext planningContext) { + OrchestrationInput input = new OrchestrationInput( + plannerId, + agents.size(), + maxIterations, + testExitAtLoopEnd); + + workflowClient.scheduleNewWorkflow( + WorkflowNameResolver.resolve(workflowClass), input, plannerId); + return internalNextAction(); + } + + @Override + public Action nextAction(PlanningContext planningContext) { + // Clear the per-agent Dapr context now that the previous agent has finished. + DaprAgentContextHolder.clear(); + // Complete one future per call. LangChain4j calls nextAction() once per agent + // from separate threads in parallel execution. + CompletableFuture future = pendingFutures.poll(); + if (future != null) { + future.complete(null); + } + + // Signal the orchestration workflow that this agent completed and clean up. + PendingAgentInfo info = pendingAgentInfos.poll(); + if (info != null) { + try { + // Send "done" to the per-agent AgentRunWorkflow + workflowClient.raiseEvent(info.agentRunId(), "agent-event", + new AgentEvent("done", null, null, null)); + LOG.infof("[Planner:%s] Sent done event to AgentRunWorkflow — agentRunId=%s", + plannerId, info.agentRunId()); + DaprAgentRunRegistry.unregister(info.agentRunId()); + // Signal the orchestration workflow that this agent has completed + workflowClient.raiseEvent(plannerId, "agent-complete-" + info.agentRunId(), null); + LOG.infof("[Planner:%s] Raised agent-complete event — agentRunId=%s", + plannerId, info.agentRunId()); + } catch (Exception ex) { + LOG.warnf("[Planner:%s] Failed to signal agent completion for agentRunId=%s: %s", + plannerId, info.agentRunId(), ex.getMessage()); + } + } + + return internalNextAction(); + } + + /** + * Core synchronization: drains the agent exchange queue and batches + * agent calls for Langchain4j to execute. + * + *

Uses a {@link ReentrantLock} so that exactly one thread blocks on the exchange + * queue while other threads (from LangChain4j's parallel executor) return + * {@code done()} immediately. LangChain4j's {@code composeActions()} correctly + * merges {@code done() + call(batch) → call(batch)} and + * {@code done() + done() → done()}, so the composed result is always correct. + * + *

For sequential (single-agent) batches, sets {@link DaprAgentContextHolder} so that + * {@link io.quarkiverse.dapr.langchain4j.agent.DaprToolCallInterceptor} can route any + * {@code @Tool} calls made by the agent through the corresponding + * {@link io.quarkiverse.dapr.langchain4j.agent.workflow.AgentRunWorkflow}. + */ + private Action internalNextAction() { + if (workflowDone) { + return done(); + } + + // Only one thread should block waiting for the next batch. + // Other threads return done() — LangChain4j's composeActions() ensures + // done() + call(batch) → call(batch), so the batch is not lost. + if (!batchLock.tryLock()) { + return done(); + } + + try { + if (workflowDone) { + return done(); + } + + // Drain all queued agent exchanges + List exchanges = new ArrayList<>(); + try { + // Block for the first one + LOG.debugf("[Planner:%s] Waiting for agent exchanges on queue...", plannerId); + AgentExchange first = agentExchangeQueue.take(); + exchanges.add(first); + // Drain any additional ones that arrived + agentExchangeQueue.drainTo(exchanges); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + workflowDone = true; + cleanup(); + return done(); + } + + // Check for sentinel (null agent = workflow completed) + List batch = new ArrayList<>(); + for (AgentExchange exchange : exchanges) { + if (exchange.agent() == null) { + workflowDone = true; + cleanup(); + return done(); + } + batch.add(exchange.agent()); + } + + if (batch.isEmpty()) { + workflowDone = true; + cleanup(); + return done(); + } + + // Store all futures — one per agent. nextAction() is called once per agent + // (possibly from different threads), each call polls and completes one future. + pendingFutures.clear(); + pendingAgentInfos.clear(); + for (AgentExchange exchange : exchanges) { + pendingFutures.add(exchange.continuation()); + pendingAgentInfos.add(new PendingAgentInfo(exchange.agentRunId())); + } + + // For sequential execution (single agent), set the Dapr agent context so that + // DaprToolCallInterceptor can route @Tool calls through the AgentRunWorkflow. + if (exchanges.size() == 1 && exchanges.get(0).agentRunId() != null) { + DaprAgentContextHolder.set(exchanges.get(0).agentRunId()); + } + + return call(batch); + } finally { + batchLock.unlock(); + } + } + + /** + * Called by {@link io.quarkiverse.dapr.langchain4j.workflow.orchestration.activities.AgentExecutionActivity} + * to submit an agent for execution and wait for completion. + * + * @param agent the agent to execute + * @param agentRunId unique ID for this agent's per-run Dapr Workflow; forwarded to the + * planner so it can set {@link DaprAgentContextHolder} on the executing thread + * @return a future that completes when the planner has processed this agent + */ + public CompletableFuture executeAgent(AgentInstance agent, String agentRunId) { + CompletableFuture future = new CompletableFuture<>(); + agentExchangeQueue.add(new AgentExchange(agent, future, agentRunId)); + return future; + } + + /** + * Signals workflow completion by posting a sentinel to the queue. + */ + public void signalWorkflowComplete() { + LOG.infof("[Planner:%s] signalWorkflowComplete() — posting sentinel to queue", plannerId); + agentExchangeQueue.add(new AgentExchange(null, null, null)); + } + + /** + * Returns the agent at the given index. + * + * @param index the agent index + * @return the agent instance + */ + public AgentInstance getAgent(int index) { + return agents.get(index); + } + + /** + * Extracts metadata (name, user message template, system message template) from + * the {@link AgentInstance} at the given index. + * + *

The system and user message templates are extracted via reflection on the + * {@code @Agent}-annotated methods of {@link AgentInstance#type()}. If no annotated + * method is found, or the agent type is not reflectable, the messages will be {@code null}. + * + * @param index the index of the agent + * @return the agent metadata + */ + public AgentMetadata getAgentMetadata(int index) { + AgentInstance agent = agents.get(index); + String agentName = agent.name(); + String um = null; + String sm = null; + + try { + Class agentType = agent.type(); + if (agentType != null) { + for (Method method : agentType.getMethods()) { + if (method.isAnnotationPresent(Agent.class)) { + UserMessage userAnnotation = method.getAnnotation(UserMessage.class); + if (userAnnotation != null && userAnnotation.value().length > 0) { + um = String.join("\n", userAnnotation.value()); + } + SystemMessage systemAnnotation = method.getAnnotation(SystemMessage.class); + if (systemAnnotation != null && systemAnnotation.value().length > 0) { + sm = String.join("\n", systemAnnotation.value()); + } + break; + } + } + } + } catch (Exception ex) { + LOG.debugf("Could not extract prompt metadata from agent type for agent=%s: %s", + agentName, ex.getMessage()); + } + + return new AgentMetadata(agentName, um, sm); + } + + /** + * Returns the agentic scope. + * + * @return the agentic scope + */ + @SuppressWarnings("EI_EXPOSE_REP") + public AgenticScope getAgenticScope() { + return agenticScope; + } + + /** + * Evaluates the exit condition for loop workflows. + * + * @param iteration the current iteration number + * @return true if the loop should exit + */ + public boolean checkExitCondition(int iteration) { + if (exitCondition == null) { + return false; + } + return exitCondition.test(agenticScope, iteration); + } + + /** + * Evaluates whether a conditional agent should execute. + * + * @param agentIndex the index of the agent + * @return true if the agent should execute + */ + public boolean checkCondition(int agentIndex) { + if (conditions == null || !conditions.containsKey(agentIndex)) { + return true; // no condition means always execute + } + return conditions.get(agentIndex).test(agenticScope); + } + + /** + * Returns the planner ID. + * + * @return the planner ID + */ + public String getPlannerId() { + return plannerId; + } + + /** + * Returns the number of agents. + * + * @return the number of agents + */ + public int getAgentCount() { + return agents.size(); + } + + // Configuration setters (called by agent service builders) + + /** + * Sets the maximum number of iterations. + * + * @param maxIterations the max iterations + */ + public void setMaxIterations(int maxIterations) { + this.maxIterations = maxIterations; + } + + /** + * Sets the exit condition predicate. + * + * @param exitCondition the exit condition + */ + public void setExitCondition(BiPredicate exitCondition) { + this.exitCondition = exitCondition; + } + + /** + * Sets whether to test the exit condition at loop end. + * + * @param testExitAtLoopEnd true to test at loop end + */ + public void setTestExitAtLoopEnd(boolean testExitAtLoopEnd) { + this.testExitAtLoopEnd = testExitAtLoopEnd; + } + + /** + * Sets the conditions map for conditional agents. + * + * @param conditions the conditions map + */ + public void setConditions(Map> conditions) { + this.conditions = conditions == null ? Collections.emptyMap() : Map.copyOf(conditions); + } + + private void cleanup() { + DaprAgentContextHolder.clear(); + DaprPlannerRegistry.unregister(plannerId); + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/WorkflowNameResolver.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/WorkflowNameResolver.java new file mode 100644 index 0000000000..f9ac42bd5b --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/WorkflowNameResolver.java @@ -0,0 +1,42 @@ +/* + * 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.quarkiverse.dapr.langchain4j.workflow; + +import io.dapr.workflows.Workflow; +import io.quarkiverse.dapr.workflows.WorkflowMetadata; + +/** + * Resolves the registration name for a {@link Workflow} class. + * If the class is annotated with {@link WorkflowMetadata} and provides a non-empty + * {@code name}, that name is returned; otherwise, the fully-qualified class name is used. + */ +public final class WorkflowNameResolver { + + private WorkflowNameResolver() { + } + + /** + * Returns the Dapr registration name for the given workflow class. + * + * @param workflowClass the workflow class to resolve the name for + * @return the Dapr registration name + */ + public static String resolve(Class workflowClass) { + WorkflowMetadata meta = workflowClass.getAnnotation(WorkflowMetadata.class); + if (meta != null && !meta.name().isEmpty()) { + return meta.name(); + } + return workflowClass.getCanonicalName(); + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/AgentExecInput.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/AgentExecInput.java new file mode 100644 index 0000000000..37f78a82cd --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/AgentExecInput.java @@ -0,0 +1,25 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.quarkiverse.dapr.langchain4j.workflow.orchestration; + +/** + * Input for the AgentExecutionActivity. + * + * @param plannerId the planner ID to look up in the registry + * @param agentIndex the index of the agent in the planner's agent list + * @param agentRunId the unique ID for this agent execution, must match the child + * AgentRunWorkflow instance ID so raiseEvent() reaches the right workflow + */ +public record AgentExecInput(String plannerId, int agentIndex, String agentRunId) { +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ConditionCheckInput.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ConditionCheckInput.java new file mode 100644 index 0000000000..841086927f --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ConditionCheckInput.java @@ -0,0 +1,23 @@ +/* + * 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.quarkiverse.dapr.langchain4j.workflow.orchestration; + +/** + * Input for the ConditionCheckActivity (used by conditional workflows). + * + * @param plannerId the planner ID to look up in the registry + * @param agentIndex the index of the agent whose condition should be evaluated + */ +public record ConditionCheckInput(String plannerId, int agentIndex) { +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ConditionalOrchestrationWorkflow.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ConditionalOrchestrationWorkflow.java new file mode 100644 index 0000000000..3ecc46987b --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ConditionalOrchestrationWorkflow.java @@ -0,0 +1,67 @@ +/* + * 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.quarkiverse.dapr.langchain4j.workflow.orchestration; + +import io.dapr.workflows.Workflow; +import io.dapr.workflows.WorkflowStub; +import io.quarkiverse.dapr.langchain4j.agent.workflow.AgentRunInput; +import io.quarkiverse.dapr.langchain4j.workflow.DaprPlannerRegistry; +import io.quarkiverse.dapr.langchain4j.workflow.DaprWorkflowPlanner; +import io.quarkiverse.dapr.langchain4j.workflow.DaprWorkflowPlanner.AgentMetadata; +import io.quarkiverse.dapr.workflows.WorkflowMetadata; +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Dapr Workflow that conditionally executes agents based on runtime predicates. + * For each agent, a condition check activity determines whether to execute it. + * Each agent is run as a child {@code AgentRunWorkflow} with a non-blocking bridging + * activity for planner coordination. Completion is detected via external events + * raised by {@link DaprWorkflowPlanner#nextAction}. + */ +@ApplicationScoped +@WorkflowMetadata(name = "conditional-agent") +public class ConditionalOrchestrationWorkflow implements Workflow { + + @Override + public WorkflowStub create() { + return ctx -> { + OrchestrationInput input = ctx.getInput(OrchestrationInput.class); + DaprWorkflowPlanner planner = DaprPlannerRegistry.get(input.plannerId()); + + for (int i = 0; i < input.agentCount(); i++) { + boolean shouldExec = ctx.callActivity("condition-check", + new ConditionCheckInput(input.plannerId(), i), + Boolean.class).await(); + if (shouldExec) { + String agentRunId = input.plannerId() + ":" + i; + AgentMetadata metadata = planner.getAgentMetadata(i); + AgentRunInput agentInput = new AgentRunInput(agentRunId, metadata.agentName(), + metadata.userMessage(), metadata.systemMessage()); + + var childWorkflow = ctx.callChildWorkflow("agent", agentInput, agentRunId, Void.class); + // Submit agent to planner (non-blocking activity — returns immediately) + ctx.callActivity("agent-call", + new AgentExecInput(input.plannerId(), i, agentRunId), Void.class).await(); + // Wait for agent completion (signaled by planner's nextAction) + ctx.waitForExternalEvent("agent-complete-" + agentRunId, Void.class).await(); + childWorkflow.await(); + } + } + // Signal planner that the workflow has completed + if (planner != null) { + planner.signalWorkflowComplete(); + } + }; + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ExitConditionCheckInput.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ExitConditionCheckInput.java new file mode 100644 index 0000000000..d1ecac8136 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ExitConditionCheckInput.java @@ -0,0 +1,23 @@ +/* + * 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.quarkiverse.dapr.langchain4j.workflow.orchestration; + +/** + * Input for the ExitConditionCheckActivity (used by loop workflows). + * + * @param plannerId the planner ID to look up in the registry + * @param iteration the current loop iteration number + */ +public record ExitConditionCheckInput(String plannerId, int iteration) { +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/LoopOrchestrationWorkflow.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/LoopOrchestrationWorkflow.java new file mode 100644 index 0000000000..b88860c5f4 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/LoopOrchestrationWorkflow.java @@ -0,0 +1,86 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.quarkiverse.dapr.langchain4j.workflow.orchestration; + +import io.dapr.workflows.Workflow; +import io.dapr.workflows.WorkflowStub; +import io.quarkiverse.dapr.langchain4j.agent.workflow.AgentRunInput; +import io.quarkiverse.dapr.langchain4j.workflow.DaprPlannerRegistry; +import io.quarkiverse.dapr.langchain4j.workflow.DaprWorkflowPlanner; +import io.quarkiverse.dapr.langchain4j.workflow.DaprWorkflowPlanner.AgentMetadata; +import io.quarkiverse.dapr.workflows.WorkflowMetadata; +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Dapr Workflow that loops through agents repeatedly until an exit condition + * is met or the maximum number of iterations is reached. + * + *

Each agent is run as a child {@code AgentRunWorkflow} with a non-blocking bridging + * activity for planner coordination. Completion is detected via external events + * raised by {@link DaprWorkflowPlanner#nextAction}. + */ +@ApplicationScoped +@WorkflowMetadata(name = "loop-agent") +public class LoopOrchestrationWorkflow implements Workflow { + + @Override + public WorkflowStub create() { + return ctx -> { + OrchestrationInput input = ctx.getInput(OrchestrationInput.class); + DaprWorkflowPlanner planner = DaprPlannerRegistry.get(input.plannerId()); + + for (int iter = 0; iter < input.maxIterations(); iter++) { + // Check exit condition at loop start (unless configured to check at end) + if (!input.testExitAtLoopEnd()) { + boolean exit = ctx.callActivity("exit-condition-check", + new ExitConditionCheckInput(input.plannerId(), iter), + Boolean.class).await(); + if (exit) { + break; + } + } + + // Execute all agents sequentially within this iteration + for (int i = 0; i < input.agentCount(); i++) { + String agentRunId = input.plannerId() + ":" + iter + ":" + i; + AgentMetadata metadata = planner.getAgentMetadata(i); + AgentRunInput agentInput = new AgentRunInput(agentRunId, metadata.agentName(), + metadata.userMessage(), metadata.systemMessage()); + + var childWorkflow = ctx.callChildWorkflow("agent", agentInput, agentRunId, Void.class); + // Submit agent to planner (non-blocking activity -- returns immediately) + ctx.callActivity("agent-call", + new AgentExecInput(input.plannerId(), i, agentRunId), Void.class).await(); + // Wait for agent completion (signaled by planner's nextAction) + ctx.waitForExternalEvent("agent-complete-" + agentRunId, Void.class).await(); + childWorkflow.await(); + } + + // Check exit condition at loop end (if configured) + if (input.testExitAtLoopEnd()) { + boolean exit = ctx.callActivity("exit-condition-check", + new ExitConditionCheckInput(input.plannerId(), iter), + Boolean.class).await(); + if (exit) { + break; + } + } + } + // Signal planner that the workflow has completed + if (planner != null) { + planner.signalWorkflowComplete(); + } + }; + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/OrchestrationInput.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/OrchestrationInput.java new file mode 100644 index 0000000000..d2e754e57d --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/OrchestrationInput.java @@ -0,0 +1,25 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.quarkiverse.dapr.langchain4j.workflow.orchestration; + +/** + * Input data passed to all Dapr orchestration workflows. + * + * @param plannerId unique planner ID (used to look up the planner in the registry) + * @param agentCount number of sub-agents to execute + * @param maxIterations maximum loop iterations (only used by LoopOrchestrationWorkflow) + * @param testExitAtLoopEnd whether to test exit condition at loop end vs. loop start + */ +public record OrchestrationInput(String plannerId, int agentCount, int maxIterations, boolean testExitAtLoopEnd) { +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ParallelOrchestrationWorkflow.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ParallelOrchestrationWorkflow.java new file mode 100644 index 0000000000..d2c4c8420c --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ParallelOrchestrationWorkflow.java @@ -0,0 +1,76 @@ +/* + * 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.quarkiverse.dapr.langchain4j.workflow.orchestration; + +import io.dapr.durabletask.Task; +import io.dapr.workflows.Workflow; +import io.dapr.workflows.WorkflowStub; +import io.quarkiverse.dapr.langchain4j.agent.workflow.AgentRunInput; +import io.quarkiverse.dapr.langchain4j.workflow.DaprPlannerRegistry; +import io.quarkiverse.dapr.langchain4j.workflow.DaprWorkflowPlanner; +import io.quarkiverse.dapr.langchain4j.workflow.DaprWorkflowPlanner.AgentMetadata; +import io.quarkiverse.dapr.workflows.WorkflowMetadata; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.ArrayList; +import java.util.List; + +/** + * Dapr Workflow that executes all agents in parallel and waits for all to complete. + * + *

Each agent is run as a child {@code AgentRunWorkflow} with a non-blocking bridging + * activity for planner coordination. Completion is detected via external events + * raised by {@link DaprWorkflowPlanner#nextAction}. + */ +@ApplicationScoped +@WorkflowMetadata(name = "parallel-agent") +public class ParallelOrchestrationWorkflow implements Workflow { + + @Override + public WorkflowStub create() { + return ctx -> { + OrchestrationInput input = ctx.getInput(OrchestrationInput.class); + DaprWorkflowPlanner planner = DaprPlannerRegistry.get(input.plannerId()); + + List> childWorkflows = new ArrayList<>(); + List> submitTasks = new ArrayList<>(); + List> completionEvents = new ArrayList<>(); + for (int i = 0; i < input.agentCount(); i++) { + String agentRunId = input.plannerId() + ":" + i; + AgentMetadata metadata = planner.getAgentMetadata(i); + AgentRunInput agentInput = new AgentRunInput(agentRunId, metadata.agentName(), + metadata.userMessage(), metadata.systemMessage()); + + // Start AgentRunWorkflow as a child workflow for proper nesting + childWorkflows.add(ctx.callChildWorkflow("agent", agentInput, agentRunId, Void.class)); + // Submit agent to planner (non-blocking activity -- returns immediately) + submitTasks.add(ctx.callActivity("agent-call", + new AgentExecInput(input.plannerId(), i, agentRunId), Void.class)); + // Register event listener for agent completion (signaled by planner's nextAction) + completionEvents.add( + ctx.waitForExternalEvent("agent-complete-" + agentRunId, Void.class)); + } + // Wait for all agents to be submitted + ctx.allOf(submitTasks).await(); + // Wait for all agents to complete (planner raises events after each agent finishes) + ctx.allOf(completionEvents).await(); + // Wait for all child AgentRunWorkflows to finish (they received "done" from planner) + ctx.allOf(childWorkflows).await(); + // Signal planner that the workflow has completed + if (planner != null) { + planner.signalWorkflowComplete(); + } + }; + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/SequentialOrchestrationWorkflow.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/SequentialOrchestrationWorkflow.java new file mode 100644 index 0000000000..531535e617 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/SequentialOrchestrationWorkflow.java @@ -0,0 +1,62 @@ +/* + * 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.quarkiverse.dapr.langchain4j.workflow.orchestration; + +import io.dapr.workflows.Workflow; +import io.dapr.workflows.WorkflowStub; +import io.quarkiverse.dapr.langchain4j.agent.workflow.AgentRunInput; +import io.quarkiverse.dapr.langchain4j.workflow.DaprPlannerRegistry; +import io.quarkiverse.dapr.langchain4j.workflow.DaprWorkflowPlanner; +import io.quarkiverse.dapr.langchain4j.workflow.DaprWorkflowPlanner.AgentMetadata; +import io.quarkiverse.dapr.workflows.WorkflowMetadata; +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Dapr Workflow that executes agents sequentially, one after another. + * Each agent is run as a child {@code AgentRunWorkflow} with a non-blocking bridging + * activity for planner coordination. Completion is detected via external events + * raised by {@link DaprWorkflowPlanner#nextAction}. + */ +@ApplicationScoped +@WorkflowMetadata(name = "sequential-agent") +public class SequentialOrchestrationWorkflow implements Workflow { + + @Override + public WorkflowStub create() { + return ctx -> { + OrchestrationInput input = ctx.getInput(OrchestrationInput.class); + DaprWorkflowPlanner planner = DaprPlannerRegistry.get(input.plannerId()); + + for (int i = 0; i < input.agentCount(); i++) { + String agentRunId = input.plannerId() + ":" + i; + AgentMetadata metadata = planner.getAgentMetadata(i); + AgentRunInput agentInput = new AgentRunInput(agentRunId, metadata.agentName(), + metadata.userMessage(), metadata.systemMessage()); + + // Start AgentRunWorkflow as a child workflow for proper nesting + var childWorkflow = ctx.callChildWorkflow("agent", agentInput, agentRunId, Void.class); + // Submit agent to planner (non-blocking activity — returns immediately) + ctx.callActivity("agent-call", + new AgentExecInput(input.plannerId(), i, agentRunId), Void.class).await(); + // Wait for agent completion (signaled by planner's nextAction) + ctx.waitForExternalEvent("agent-complete-" + agentRunId, Void.class).await(); + childWorkflow.await(); + } + // Signal planner that the workflow has completed + if (planner != null) { + planner.signalWorkflowComplete(); + } + }; + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/activities/AgentExecutionActivity.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/activities/AgentExecutionActivity.java new file mode 100644 index 0000000000..b01e9e2217 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/activities/AgentExecutionActivity.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.quarkiverse.dapr.langchain4j.workflow.orchestration.activities; + +import io.dapr.workflows.WorkflowActivity; +import io.dapr.workflows.WorkflowActivityContext; +import io.quarkiverse.dapr.langchain4j.agent.AgentRunContext; +import io.quarkiverse.dapr.langchain4j.agent.DaprAgentRunRegistry; +import io.quarkiverse.dapr.langchain4j.workflow.DaprPlannerRegistry; +import io.quarkiverse.dapr.langchain4j.workflow.DaprWorkflowPlanner; +import io.quarkiverse.dapr.langchain4j.workflow.DaprWorkflowPlanner.AgentMetadata; +import io.quarkiverse.dapr.langchain4j.workflow.orchestration.AgentExecInput; +import io.quarkiverse.dapr.workflows.ActivityMetadata; +import jakarta.enterprise.context.ApplicationScoped; +import org.jboss.logging.Logger; +/** + * Dapr WorkflowActivity that bridges the Dapr Workflow execution to the + * LangChain4j planner. When invoked by an orchestration workflow alongside a + * child {@link io.quarkiverse.dapr.langchain4j.agent.workflow.AgentRunWorkflow}, it: + *

    + *
  1. Looks up the planner from the registry.
  2. + *
  3. Creates a per-agent {@link AgentRunContext} and registers it.
  4. + *
  5. Submits the agent to the planner's exchange queue (along with its {@code agentRunId}) + * so the planner can set {@link io.quarkiverse.dapr.langchain4j.agent.DaprAgentContextHolder} + * on the executing thread before tool calls begin.
  6. + *
  7. Returns immediately — the planner's {@code nextAction()} handles completion + * signaling (sending {@code "done"} to the AgentRunWorkflow, raising an external + * event to the orchestration workflow, and cleaning up the registry).
  8. + *
+ * + *

This activity is intentionally non-blocking to avoid exhausting the Dapr + * activity thread pool when composite agents (e.g., a {@code @SequenceAgent} nested + * inside a {@code @ParallelAgent}) spawn additional activities for their inner workflows. + */ + +@ApplicationScoped +@ActivityMetadata(name = "agent-call") +public class AgentExecutionActivity implements WorkflowActivity { + + private static final Logger LOG = Logger.getLogger(AgentExecutionActivity.class); + + @Override + public Object run(WorkflowActivityContext ctx) { + AgentExecInput input = ctx.getInput(AgentExecInput.class); + DaprWorkflowPlanner planner = DaprPlannerRegistry.get(input.plannerId()); + if (planner == null) { + throw new IllegalStateException("No planner found for ID: " + input.plannerId() + + ". Registered IDs: " + DaprPlannerRegistry.getRegisteredIds()); + } + + AgentMetadata metadata = planner.getAgentMetadata(input.agentIndex()); + String agentName = metadata.agentName(); + String agentRunId = input.agentRunId(); + + LOG.infof("[Planner:%s] AgentExecutionActivity started — agent=%s, agentRunId=%s", + input.plannerId(), agentName, agentRunId); + + AgentRunContext runContext = new AgentRunContext(agentRunId); + DaprAgentRunRegistry.register(agentRunId, runContext); + + // Submit the agent to the planner's exchange queue (non-blocking). + // The planner's nextAction() handles completion signaling and cleanup. + planner.executeAgent(planner.getAgent(input.agentIndex()), agentRunId); + + LOG.infof("[Planner:%s] AgentExecutionActivity submitted — agent=%s, agentRunId=%s", + input.plannerId(), agentName, agentRunId); + + return null; + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/activities/ConditionCheckActivity.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/activities/ConditionCheckActivity.java new file mode 100644 index 0000000000..c377839e91 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/activities/ConditionCheckActivity.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.quarkiverse.dapr.langchain4j.workflow.orchestration.activities; + +import io.dapr.workflows.WorkflowActivity; +import io.dapr.workflows.WorkflowActivityContext; +import io.quarkiverse.dapr.langchain4j.workflow.DaprPlannerRegistry; +import io.quarkiverse.dapr.langchain4j.workflow.DaprWorkflowPlanner; +import io.quarkiverse.dapr.langchain4j.workflow.orchestration.ConditionCheckInput; +import io.quarkiverse.dapr.workflows.ActivityMetadata; +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Dapr WorkflowActivity that checks whether a conditional agent should execute. + * Returns {@code true} if the agent's condition is met, {@code false} otherwise. + */ +@ApplicationScoped +@ActivityMetadata(name = "condition-check") +public class ConditionCheckActivity implements WorkflowActivity { + + @Override + public Object run(WorkflowActivityContext ctx) { + ConditionCheckInput input = ctx.getInput(ConditionCheckInput.class); + DaprWorkflowPlanner planner = DaprPlannerRegistry.get(input.plannerId()); + if (planner == null) { + throw new IllegalStateException("No planner found for ID: " + input.plannerId()); + } + return planner.checkCondition(input.agentIndex()); + } +} diff --git a/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/activities/ExitConditionCheckActivity.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/activities/ExitConditionCheckActivity.java new file mode 100644 index 0000000000..7c65c39866 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/activities/ExitConditionCheckActivity.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.quarkiverse.dapr.langchain4j.workflow.orchestration.activities; + +import io.dapr.workflows.WorkflowActivity; +import io.dapr.workflows.WorkflowActivityContext; +import io.quarkiverse.dapr.langchain4j.workflow.DaprPlannerRegistry; +import io.quarkiverse.dapr.langchain4j.workflow.DaprWorkflowPlanner; +import io.quarkiverse.dapr.langchain4j.workflow.orchestration.ExitConditionCheckInput; +import io.quarkiverse.dapr.workflows.ActivityMetadata; +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Dapr WorkflowActivity that checks the exit condition for loop workflows. + * Returns {@code true} if the loop should exit, {@code false} otherwise. + */ +@ApplicationScoped +@ActivityMetadata(name = "exit-condition-check") +public class ExitConditionCheckActivity implements WorkflowActivity { + + @Override + public Object run(WorkflowActivityContext ctx) { + ExitConditionCheckInput input = ctx.getInput(ExitConditionCheckInput.class); + DaprWorkflowPlanner planner = DaprPlannerRegistry.get(input.plannerId()); + if (planner == null) { + throw new IllegalStateException("No planner found for ID: " + input.plannerId()); + } + return planner.checkExitCondition(input.iteration()); + } +} diff --git a/quarkus/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/quarkus/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 0000000000..5ef11eeba9 --- /dev/null +++ b/quarkus/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,11 @@ +name: Agentic Dapr +description: Agentic Dapr +artifact: ${project.groupId}:${project.artifactId}:${project.version} +metadata: + keywords: + - ai + - agentic + - cncf + - dapr + categories: + - "integration" \ No newline at end of file diff --git a/quarkus/runtime/src/main/resources/META-INF/services/dev.langchain4j.agentic.workflow.WorkflowAgentsBuilder b/quarkus/runtime/src/main/resources/META-INF/services/dev.langchain4j.agentic.workflow.WorkflowAgentsBuilder new file mode 100644 index 0000000000..ba35d9f09d --- /dev/null +++ b/quarkus/runtime/src/main/resources/META-INF/services/dev.langchain4j.agentic.workflow.WorkflowAgentsBuilder @@ -0,0 +1 @@ +io.quarkiverse.dapr.langchain4j.workflow.DaprWorkflowAgentsBuilder \ No newline at end of file diff --git a/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/memory/KeyValueChatMemoryStoreTest.java b/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/memory/KeyValueChatMemoryStoreTest.java new file mode 100644 index 0000000000..26ae43a11b --- /dev/null +++ b/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/memory/KeyValueChatMemoryStoreTest.java @@ -0,0 +1,195 @@ +package io.quarkiverse.dapr.langchain4j.memory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import io.dapr.client.DaprClient; +import io.dapr.client.domain.State; +import reactor.core.publisher.Mono; + +class KeyValueChatMemoryStoreTest { + + private static final String STATE_STORE_NAME = "statestore"; + + private DaprClient daprClient; + private KeyValueChatMemoryStore store; + + /** + * Simple serializer for testing: stores the message type and text, one per line. + * Format: "TYPE:text\nTYPE:text\n..." + */ + private static final Function, String> TEST_SERIALIZER = messages -> { + StringBuilder sb = new StringBuilder(); + for (ChatMessage msg : messages) { + sb.append(msg.type().name()).append(":"); + switch (msg.type()) { + case SYSTEM -> sb.append(((SystemMessage) msg).text()); + case USER -> sb.append(((UserMessage) msg).singleText()); + case AI -> sb.append(((AiMessage) msg).text()); + default -> sb.append("unknown"); + } + sb.append("\n"); + } + return sb.toString(); + }; + + private static final Function> TEST_DESERIALIZER = json -> { + List messages = new ArrayList<>(); + for (String line : json.split("\n")) { + if (line.isEmpty()) + continue; + String[] parts = line.split(":", 2); + String type = parts[0]; + String text = parts[1]; + switch (type) { + case "SYSTEM" -> messages.add(new SystemMessage(text)); + case "USER" -> messages.add(new UserMessage(text)); + case "AI" -> messages.add(new AiMessage(text)); + } + } + return messages; + }; + + @BeforeEach + void setUp() { + daprClient = mock(DaprClient.class); + store = new KeyValueChatMemoryStore(daprClient, STATE_STORE_NAME, + TEST_SERIALIZER, TEST_DESERIALIZER); + } + + @Test + void getMessagesShouldReturnEmptyListWhenKeyDoesNotExist() { + State emptyState = new State<>("user-1", (String) null, (String) null); + when(daprClient.getState(eq(STATE_STORE_NAME), eq("user-1"), eq(String.class))) + .thenReturn(Mono.just(emptyState)); + + List messages = store.getMessages("user-1"); + + assertThat(messages).isEmpty(); + verify(daprClient).getState(STATE_STORE_NAME, "user-1", String.class); + } + + @Test + void getMessagesShouldReturnEmptyListWhenValueIsEmpty() { + State emptyState = new State<>("user-1", "", (String) null); + when(daprClient.getState(eq(STATE_STORE_NAME), eq("user-1"), eq(String.class))) + .thenReturn(Mono.just(emptyState)); + + List messages = store.getMessages("user-1"); + + assertThat(messages).isEmpty(); + } + + @Test + void updateMessagesShouldSaveSerializedMessages() { + when(daprClient.saveState(eq(STATE_STORE_NAME), eq("user-1"), any(String.class))) + .thenReturn(Mono.empty()); + + List messages = List.of( + new UserMessage("Hello"), + new AiMessage("Hi there!")); + + store.updateMessages("user-1", messages); + + ArgumentCaptor jsonCaptor = ArgumentCaptor.forClass(String.class); + verify(daprClient).saveState(eq(STATE_STORE_NAME), eq("user-1"), jsonCaptor.capture()); + + String savedJson = jsonCaptor.getValue(); + assertThat(savedJson).contains("USER:Hello"); + assertThat(savedJson).contains("AI:Hi there!"); + } + + @Test + void getMessagesShouldReturnDeserializedMessages() { + String stored = "SYSTEM:You are a helpful assistant\nUSER:What is Java?\nAI:A programming language.\n"; + State state = new State<>("conv-42", stored, (String) null); + when(daprClient.getState(eq(STATE_STORE_NAME), eq("conv-42"), eq(String.class))) + .thenReturn(Mono.just(state)); + + List retrieved = store.getMessages("conv-42"); + + assertThat(retrieved).hasSize(3); + assertThat(retrieved.get(0)).isInstanceOf(SystemMessage.class); + assertThat(((SystemMessage) retrieved.get(0)).text()).isEqualTo("You are a helpful assistant"); + assertThat(retrieved.get(1)).isInstanceOf(UserMessage.class); + assertThat(((UserMessage) retrieved.get(1)).singleText()).isEqualTo("What is Java?"); + assertThat(retrieved.get(2)).isInstanceOf(AiMessage.class); + assertThat(((AiMessage) retrieved.get(2)).text()).isEqualTo("A programming language."); + } + + @Test + void deleteMessagesShouldRemoveState() { + when(daprClient.deleteState(eq(STATE_STORE_NAME), eq("user-1"))) + .thenReturn(Mono.empty()); + + store.deleteMessages("user-1"); + + verify(daprClient).deleteState(STATE_STORE_NAME, "user-1"); + } + + @Test + void shouldUseMemoryIdToStringAsKey() { + when(daprClient.saveState(eq(STATE_STORE_NAME), eq("123"), any(String.class))) + .thenReturn(Mono.empty()); + + store.updateMessages(123, List.of(new UserMessage("test"))); + + verify(daprClient).saveState(eq(STATE_STORE_NAME), eq("123"), any(String.class)); + } + + @Test + void updateMessagesShouldHandleEmptyList() { + when(daprClient.saveState(eq(STATE_STORE_NAME), eq("user-1"), any(String.class))) + .thenReturn(Mono.empty()); + + store.updateMessages("user-1", List.of()); + + verify(daprClient).saveState(eq(STATE_STORE_NAME), eq("user-1"), any(String.class)); + } + + @Test + void roundTripShouldPreserveMessages() { + // Save messages + when(daprClient.saveState(eq(STATE_STORE_NAME), eq("rt-1"), any(String.class))) + .thenReturn(Mono.empty()); + + List original = List.of( + new SystemMessage("Be concise"), + new UserMessage("Hello"), + new AiMessage("Hi!")); + + store.updateMessages("rt-1", original); + + // Capture what was saved + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(daprClient).saveState(eq(STATE_STORE_NAME), eq("rt-1"), captor.capture()); + + // Now simulate reading it back + State state = new State<>("rt-1", captor.getValue(), (String) null); + when(daprClient.getState(eq(STATE_STORE_NAME), eq("rt-1"), eq(String.class))) + .thenReturn(Mono.just(state)); + + List retrieved = store.getMessages("rt-1"); + + assertThat(retrieved).hasSize(3); + assertThat(((SystemMessage) retrieved.get(0)).text()).isEqualTo("Be concise"); + assertThat(((UserMessage) retrieved.get(1)).singleText()).isEqualTo("Hello"); + assertThat(((AiMessage) retrieved.get(2)).text()).isEqualTo("Hi!"); + } +} diff --git a/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/DaprAgentServiceUtilTest.java b/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/DaprAgentServiceUtilTest.java new file mode 100644 index 0000000000..8a5704afee --- /dev/null +++ b/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/DaprAgentServiceUtilTest.java @@ -0,0 +1,38 @@ +package io.quarkiverse.dapr.langchain4j.workflow; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class DaprAgentServiceUtilTest { + + @Test + void shouldSanitizeName() { + assertThat(DaprAgentServiceUtil.safeName("hello-world_123")).isEqualTo("hello-world_123"); + } + + @Test + void shouldReplaceSpecialCharacters() { + assertThat(DaprAgentServiceUtil.safeName("my agent!@#$%")).isEqualTo("my_agent_____"); + } + + @Test + void shouldReplaceSpaces() { + assertThat(DaprAgentServiceUtil.safeName("agent name with spaces")).isEqualTo("agent_name_with_spaces"); + } + + @Test + void shouldHandleNullName() { + assertThat(DaprAgentServiceUtil.safeName(null)).isEqualTo("unnamed"); + } + + @Test + void shouldHandleEmptyName() { + assertThat(DaprAgentServiceUtil.safeName("")).isEqualTo("unnamed"); + } + + @Test + void shouldPreserveAlphanumericHyphenUnderscore() { + assertThat(DaprAgentServiceUtil.safeName("ABC-def_123")).isEqualTo("ABC-def_123"); + } +} diff --git a/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/DaprPlannerRegistryTest.java b/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/DaprPlannerRegistryTest.java new file mode 100644 index 0000000000..1fb7df20d0 --- /dev/null +++ b/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/DaprPlannerRegistryTest.java @@ -0,0 +1,52 @@ +package io.quarkiverse.dapr.langchain4j.workflow; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.mockito.Mockito.mock; + +import dev.langchain4j.agentic.planner.AgenticSystemTopology; +import io.dapr.workflows.client.DaprWorkflowClient; +import io.quarkiverse.dapr.langchain4j.workflow.orchestration.SequentialOrchestrationWorkflow; + +class DaprPlannerRegistryTest { + + @AfterEach + void cleanup() { + // Clean up any registered planners + DaprPlannerRegistry.unregister("test-id-1"); + DaprPlannerRegistry.unregister("test-id-2"); + } + + @Test + void shouldRegisterAndRetrievePlanner() { + DaprWorkflowClient client = mock(DaprWorkflowClient.class); + DaprWorkflowPlanner planner = new DaprWorkflowPlanner( + SequentialOrchestrationWorkflow.class, "test", AgenticSystemTopology.SEQUENCE, client); + + String id = planner.getPlannerId(); + DaprPlannerRegistry.register(id, planner); + + assertThat(DaprPlannerRegistry.get(id)).isSameAs(planner); + } + + @Test + void shouldReturnNullForUnknownId() { + assertThat(DaprPlannerRegistry.get("nonexistent")).isNull(); + } + + @Test + void shouldUnregisterPlanner() { + DaprWorkflowClient client = mock(DaprWorkflowClient.class); + DaprWorkflowPlanner planner = new DaprWorkflowPlanner( + SequentialOrchestrationWorkflow.class, "test", AgenticSystemTopology.SEQUENCE, client); + + String id = planner.getPlannerId(); + DaprPlannerRegistry.register(id, planner); + DaprPlannerRegistry.unregister(id); + + assertThat(DaprPlannerRegistry.get(id)).isNull(); + } +} diff --git a/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/DaprWorkflowAgentsBuilderTest.java b/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/DaprWorkflowAgentsBuilderTest.java new file mode 100644 index 0000000000..a073c70386 --- /dev/null +++ b/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/DaprWorkflowAgentsBuilderTest.java @@ -0,0 +1,95 @@ +package io.quarkiverse.dapr.langchain4j.workflow; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.util.ServiceLoader; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import dev.langchain4j.agentic.UntypedAgent; +import dev.langchain4j.agentic.workflow.ConditionalAgentService; +import dev.langchain4j.agentic.workflow.LoopAgentService; +import dev.langchain4j.agentic.workflow.ParallelAgentService; +import dev.langchain4j.agentic.workflow.SequentialAgentService; +import dev.langchain4j.agentic.workflow.WorkflowAgentsBuilder; +import io.dapr.workflows.client.DaprWorkflowClient; + +class DaprWorkflowAgentsBuilderTest { + + private DaprWorkflowClient workflowClient; + + @BeforeEach + void setUp() { + workflowClient = mock(DaprWorkflowClient.class); + } + + @Test + void shouldBeDiscoverableViaSPI() { + ServiceLoader loader = ServiceLoader.load(WorkflowAgentsBuilder.class); + boolean found = false; + for (WorkflowAgentsBuilder builder : loader) { + if (builder instanceof DaprWorkflowAgentsBuilder) { + found = true; + break; + } + } + assertThat(found).as("DaprWorkflowAgentsBuilder should be discoverable via ServiceLoader").isTrue(); + } + + @Test + void sequenceBuilderShouldReturnDaprSequentialAgentService() { + SequentialAgentService service = DaprSequentialAgentService.builder(workflowClient); + assertThat(service).isInstanceOf(DaprSequentialAgentService.class); + } + + @Test + void typedSequenceBuilderShouldReturnDaprSequentialAgentService() { + SequentialAgentService service = DaprSequentialAgentService.builder(MyAgentService.class, + workflowClient); + assertThat(service).isInstanceOf(DaprSequentialAgentService.class); + } + + @Test + void parallelBuilderShouldReturnDaprParallelAgentService() { + ParallelAgentService service = DaprParallelAgentService.builder(workflowClient); + assertThat(service).isInstanceOf(DaprParallelAgentService.class); + } + + @Test + void typedParallelBuilderShouldReturnDaprParallelAgentService() { + ParallelAgentService service = DaprParallelAgentService.builder(MyAgentService.class, + workflowClient); + assertThat(service).isInstanceOf(DaprParallelAgentService.class); + } + + @Test + void loopBuilderShouldReturnDaprLoopAgentService() { + LoopAgentService service = DaprLoopAgentService.builder(workflowClient); + assertThat(service).isInstanceOf(DaprLoopAgentService.class); + } + + @Test + void typedLoopBuilderShouldReturnDaprLoopAgentService() { + LoopAgentService service = DaprLoopAgentService.builder(MyAgentService.class, workflowClient); + assertThat(service).isInstanceOf(DaprLoopAgentService.class); + } + + @Test + void conditionalBuilderShouldReturnDaprConditionalAgentService() { + ConditionalAgentService service = DaprConditionalAgentService.builder(workflowClient); + assertThat(service).isInstanceOf(DaprConditionalAgentService.class); + } + + @Test + void typedConditionalBuilderShouldReturnDaprConditionalAgentService() { + ConditionalAgentService service = DaprConditionalAgentService.builder(MyAgentService.class, + workflowClient); + assertThat(service).isInstanceOf(DaprConditionalAgentService.class); + } + + /** Dummy interface for typed builder tests. */ + interface MyAgentService { + } +} diff --git a/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/DaprWorkflowPlannerTest.java b/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/DaprWorkflowPlannerTest.java new file mode 100644 index 0000000000..7734be1033 --- /dev/null +++ b/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/DaprWorkflowPlannerTest.java @@ -0,0 +1,291 @@ +package io.quarkiverse.dapr.langchain4j.workflow; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import dev.langchain4j.agentic.Agent; +import dev.langchain4j.agentic.planner.Action; +import dev.langchain4j.agentic.planner.AgentInstance; +import dev.langchain4j.agentic.planner.AgenticSystemTopology; +import dev.langchain4j.agentic.planner.InitPlanningContext; +import dev.langchain4j.agentic.planner.PlanningContext; +import dev.langchain4j.agentic.scope.AgenticScope; +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import io.dapr.workflows.client.DaprWorkflowClient; +import io.quarkiverse.dapr.langchain4j.workflow.DaprWorkflowPlanner.AgentMetadata; +import io.quarkiverse.dapr.langchain4j.workflow.orchestration.SequentialOrchestrationWorkflow; + +class DaprWorkflowPlannerTest { + + private DaprWorkflowClient workflowClient; + private DaprWorkflowPlanner planner; + private AgentInstance agent1; + private AgentInstance agent2; + private AgenticScope scope; + + @BeforeEach + void setUp() { + workflowClient = mock(DaprWorkflowClient.class); + planner = new DaprWorkflowPlanner( + SequentialOrchestrationWorkflow.class, + "test", + AgenticSystemTopology.SEQUENCE, + workflowClient); + + agent1 = mock(AgentInstance.class); + when(agent1.name()).thenReturn("agent1"); + agent2 = mock(AgentInstance.class); + when(agent2.name()).thenReturn("agent2"); + scope = mock(AgenticScope.class); + } + + @AfterEach + void tearDown() { + DaprPlannerRegistry.unregister(planner.getPlannerId()); + } + + @Test + void shouldHaveUniqueId() { + DaprWorkflowPlanner planner2 = new DaprWorkflowPlanner( + SequentialOrchestrationWorkflow.class, "test2", AgenticSystemTopology.SEQUENCE, workflowClient); + assertThat(planner.getPlannerId()).isNotEqualTo(planner2.getPlannerId()); + } + + @Test + void shouldReturnCorrectTopology() { + assertThat(planner.topology()).isEqualTo(AgenticSystemTopology.SEQUENCE); + } + + @Test + void shouldRegisterInRegistryOnInit() { + InitPlanningContext ctx = new InitPlanningContext(scope, mock(AgentInstance.class), List.of(agent1, agent2)); + planner.init(ctx); + + assertThat(DaprPlannerRegistry.get(planner.getPlannerId())).isSameAs(planner); + } + + @Test + void shouldStoreAgentsOnInit() { + InitPlanningContext ctx = new InitPlanningContext(scope, mock(AgentInstance.class), List.of(agent1, agent2)); + planner.init(ctx); + + assertThat(planner.getAgent(0)).isSameAs(agent1); + assertThat(planner.getAgent(1)).isSameAs(agent2); + } + + @Test + void shouldScheduleWorkflowOnFirstAction() { + InitPlanningContext initCtx = new InitPlanningContext(scope, mock(AgentInstance.class), List.of(agent1)); + planner.init(initCtx); + + // Simulate workflow posting a completion sentinel immediately + Thread workflowThread = new Thread(() -> { + try { + Thread.sleep(50); + planner.signalWorkflowComplete(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + workflowThread.start(); + + PlanningContext planCtx = mock(PlanningContext.class); + Action action = planner.firstAction(planCtx); + + verify(workflowClient).scheduleNewWorkflow( + eq("sequential-agent"), + any(), + eq(planner.getPlannerId())); + + // Sentinel was posted, so the planner returns done() + assertThat(action.isDone()).isTrue(); + } + + @Test + void executeAgentShouldQueueAndReturnFuture() { + CompletableFuture future = planner.executeAgent(agent1, null); + assertThat(future).isNotNull(); + assertThat(future.isDone()).isFalse(); + + // Completing the future should work + future.complete(null); + assertThat(future.isDone()).isTrue(); + } + + @Test + void signalWorkflowCompleteShouldPostSentinel() throws Exception { + // Pre-load an agent exchange + a completion sentinel + planner.executeAgent(agent1, null); + planner.signalWorkflowComplete(); + + // The planner should be able to drain both: first the agent, then the sentinel + // We verify via internalNextAction indirectly through firstAction + InitPlanningContext initCtx = new InitPlanningContext(scope, mock(AgentInstance.class), List.of(agent1)); + planner.init(initCtx); + + // Skip firstAction's scheduleNewWorkflow - agent + sentinel already queued + // First drain returns the agent + // Second drain (via nextAction) returns done + + // This test verifies the sentinel mechanism works + assertThat(planner.getPlannerId()).isNotNull(); + } + + @Test + void shouldEvaluateExitConditionAsFalseWhenNull() { + assertThat(planner.checkExitCondition(0)).isFalse(); + } + + @Test + void shouldEvaluateExitCondition() { + InitPlanningContext initCtx = new InitPlanningContext(scope, mock(AgentInstance.class), List.of(agent1)); + planner.init(initCtx); + + planner.setExitCondition((s, iter) -> iter >= 3); + + assertThat(planner.checkExitCondition(0)).isFalse(); + assertThat(planner.checkExitCondition(2)).isFalse(); + assertThat(planner.checkExitCondition(3)).isTrue(); + assertThat(planner.checkExitCondition(5)).isTrue(); + } + + @Test + void shouldReturnTrueForConditionCheckWhenNoCondition() { + assertThat(planner.checkCondition(0)).isTrue(); + assertThat(planner.checkCondition(99)).isTrue(); + } + + @Test + void shouldEvaluateConditionCheck() { + InitPlanningContext initCtx = new InitPlanningContext(scope, mock(AgentInstance.class), List.of(agent1, agent2)); + planner.init(initCtx); + + Predicate alwaysTrue = s -> true; + Predicate alwaysFalse = s -> false; + planner.setConditions(Map.of(0, alwaysTrue, 1, alwaysFalse)); + + assertThat(planner.checkCondition(0)).isTrue(); + assertThat(planner.checkCondition(1)).isFalse(); + } + + @Test + void shouldHandleConcurrentExecuteAgentCalls() throws Exception { + CountDownLatch latch = new CountDownLatch(2); + + Thread t1 = new Thread(() -> { + planner.executeAgent(agent1, null); + latch.countDown(); + }); + Thread t2 = new Thread(() -> { + planner.executeAgent(agent2, null); + latch.countDown(); + }); + + t1.start(); + t2.start(); + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + } + + // --- Test interfaces for getAgentMetadata() --- + + interface AnnotatedAgent { + @Agent(name = "annotated") + @SystemMessage("You are a helpful assistant") + @UserMessage("Please help with: {{it}}") + String chat(String input); + } + + interface AgentWithoutMessages { + @Agent(name = "no-messages") + String chat(String input); + } + + interface AgentWithSystemOnly { + @Agent(name = "system-only") + @SystemMessage("System prompt here") + String chat(String input); + } + + // --- Tests for getAgentMetadata() --- + + @Test + void getAgentMetadataShouldExtractAnnotations() { + AgentInstance agent = mock(AgentInstance.class); + when(agent.name()).thenReturn("annotated"); + when(agent.type()).thenReturn((Class) AnnotatedAgent.class); + + InitPlanningContext ctx = new InitPlanningContext(scope, mock(AgentInstance.class), List.of(agent)); + planner.init(ctx); + + AgentMetadata metadata = planner.getAgentMetadata(0); + + assertThat(metadata.agentName()).isEqualTo("annotated"); + assertThat(metadata.systemMessage()).isEqualTo("You are a helpful assistant"); + assertThat(metadata.userMessage()).isEqualTo("Please help with: {{it}}"); + } + + @Test + void getAgentMetadataShouldReturnNullMessagesWhenNotAnnotated() { + AgentInstance agent = mock(AgentInstance.class); + when(agent.name()).thenReturn("no-messages"); + when(agent.type()).thenReturn((Class) AgentWithoutMessages.class); + + InitPlanningContext ctx = new InitPlanningContext(scope, mock(AgentInstance.class), List.of(agent)); + planner.init(ctx); + + AgentMetadata metadata = planner.getAgentMetadata(0); + + assertThat(metadata.agentName()).isEqualTo("no-messages"); + assertThat(metadata.systemMessage()).isNull(); + assertThat(metadata.userMessage()).isNull(); + } + + @Test + void getAgentMetadataShouldExtractSystemMessageOnly() { + AgentInstance agent = mock(AgentInstance.class); + when(agent.name()).thenReturn("system-only"); + when(agent.type()).thenReturn((Class) AgentWithSystemOnly.class); + + InitPlanningContext ctx = new InitPlanningContext(scope, mock(AgentInstance.class), List.of(agent)); + planner.init(ctx); + + AgentMetadata metadata = planner.getAgentMetadata(0); + + assertThat(metadata.agentName()).isEqualTo("system-only"); + assertThat(metadata.systemMessage()).isEqualTo("System prompt here"); + assertThat(metadata.userMessage()).isNull(); + } + + @Test + void getAgentMetadataShouldHandleNullType() { + AgentInstance agent = mock(AgentInstance.class); + when(agent.name()).thenReturn("null-type"); + when(agent.type()).thenReturn(null); + + InitPlanningContext ctx = new InitPlanningContext(scope, mock(AgentInstance.class), List.of(agent)); + planner.init(ctx); + + AgentMetadata metadata = planner.getAgentMetadata(0); + + assertThat(metadata.agentName()).isEqualTo("null-type"); + assertThat(metadata.systemMessage()).isNull(); + assertThat(metadata.userMessage()).isNull(); + } +} diff --git a/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ActivitiesTest.java b/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ActivitiesTest.java new file mode 100644 index 0000000000..dc2b367e53 --- /dev/null +++ b/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ActivitiesTest.java @@ -0,0 +1,174 @@ +package io.quarkiverse.dapr.langchain4j.workflow.orchestration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import dev.langchain4j.agentic.planner.AgentInstance; +import dev.langchain4j.agentic.planner.AgenticSystemTopology; +import dev.langchain4j.agentic.planner.InitPlanningContext; +import dev.langchain4j.agentic.scope.AgenticScope; +import io.dapr.workflows.WorkflowActivityContext; +import io.dapr.workflows.client.DaprWorkflowClient; +import io.quarkiverse.dapr.langchain4j.workflow.DaprPlannerRegistry; +import io.quarkiverse.dapr.langchain4j.workflow.DaprWorkflowPlanner; +import io.quarkiverse.dapr.langchain4j.workflow.orchestration.activities.AgentExecutionActivity; +import io.quarkiverse.dapr.langchain4j.workflow.orchestration.activities.ConditionCheckActivity; +import io.quarkiverse.dapr.langchain4j.workflow.orchestration.activities.ExitConditionCheckActivity; + +class ActivitiesTest { + + private DaprWorkflowPlanner planner; + private AgentInstance agent1; + private AgentInstance agent2; + + @BeforeEach + void setUp() { + DaprWorkflowClient client = mock(DaprWorkflowClient.class); + planner = new DaprWorkflowPlanner( + SequentialOrchestrationWorkflow.class, "test", + AgenticSystemTopology.SEQUENCE, client); + + agent1 = mock(AgentInstance.class); + when(agent1.name()).thenReturn("agent1"); + agent2 = mock(AgentInstance.class); + when(agent2.name()).thenReturn("agent2"); + AgenticScope scope = mock(AgenticScope.class); + + InitPlanningContext initCtx = new InitPlanningContext(scope, mock(AgentInstance.class), List.of(agent1, agent2)); + planner.init(initCtx); + } + + @AfterEach + void tearDown() { + DaprPlannerRegistry.unregister(planner.getPlannerId()); + } + + @Test + void agentExecutionActivityShouldSubmitAndReturnImmediately() { + AgentExecutionActivity activity = new AgentExecutionActivity(); + + WorkflowActivityContext ctx = mock(WorkflowActivityContext.class); + when(ctx.getInput(AgentExecInput.class)) + .thenReturn(new AgentExecInput(planner.getPlannerId(), 0, + planner.getPlannerId() + ":0")); + + // Activity should return immediately (non-blocking) + Object result = activity.run(ctx); + assertThat(result).isNull(); + } + + @Test + void agentExecutionActivityShouldThrowForUnknownPlanner() { + AgentExecutionActivity activity = new AgentExecutionActivity(); + + WorkflowActivityContext ctx = mock(WorkflowActivityContext.class); + when(ctx.getInput(AgentExecInput.class)) + .thenReturn(new AgentExecInput("nonexistent-planner", 0, "nonexistent-planner:0")); + + assertThatThrownBy(() -> activity.run(ctx)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No planner found"); + } + + @Test + void exitConditionCheckActivityShouldReturnFalseWhenNoCondition() { + ExitConditionCheckActivity activity = new ExitConditionCheckActivity(); + + WorkflowActivityContext ctx = mock(WorkflowActivityContext.class); + when(ctx.getInput(ExitConditionCheckInput.class)) + .thenReturn(new ExitConditionCheckInput(planner.getPlannerId(), 0)); + + assertThat(activity.run(ctx)).isEqualTo(false); + } + + @Test + void exitConditionCheckActivityShouldEvaluateCondition() { + planner.setExitCondition((s, iter) -> iter >= 2); + + ExitConditionCheckActivity activity = new ExitConditionCheckActivity(); + + WorkflowActivityContext ctx = mock(WorkflowActivityContext.class); + + when(ctx.getInput(ExitConditionCheckInput.class)) + .thenReturn(new ExitConditionCheckInput(planner.getPlannerId(), 1)); + assertThat(activity.run(ctx)).isEqualTo(false); + + when(ctx.getInput(ExitConditionCheckInput.class)) + .thenReturn(new ExitConditionCheckInput(planner.getPlannerId(), 2)); + assertThat(activity.run(ctx)).isEqualTo(true); + } + + @Test + void exitConditionCheckActivityShouldThrowForUnknownPlanner() { + ExitConditionCheckActivity activity = new ExitConditionCheckActivity(); + + WorkflowActivityContext ctx = mock(WorkflowActivityContext.class); + when(ctx.getInput(ExitConditionCheckInput.class)) + .thenReturn(new ExitConditionCheckInput("nonexistent", 0)); + + assertThatThrownBy(() -> activity.run(ctx)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void conditionCheckActivityShouldReturnTrueByDefault() { + ConditionCheckActivity activity = new ConditionCheckActivity(); + + WorkflowActivityContext ctx = mock(WorkflowActivityContext.class); + when(ctx.getInput(ConditionCheckInput.class)) + .thenReturn(new ConditionCheckInput(planner.getPlannerId(), 0)); + + assertThat(activity.run(ctx)).isEqualTo(true); + } + + @Test + void conditionCheckActivityShouldEvaluateCondition() { + Predicate alwaysFalse = s -> false; + planner.setConditions(Map.of(0, alwaysFalse)); + + ConditionCheckActivity activity = new ConditionCheckActivity(); + + WorkflowActivityContext ctx = mock(WorkflowActivityContext.class); + when(ctx.getInput(ConditionCheckInput.class)) + .thenReturn(new ConditionCheckInput(planner.getPlannerId(), 0)); + + assertThat(activity.run(ctx)).isEqualTo(false); + } + + @Test + void conditionCheckActivityShouldReturnTrueForAgentWithoutCondition() { + Predicate alwaysFalse = s -> false; + planner.setConditions(Map.of(0, alwaysFalse)); + + ConditionCheckActivity activity = new ConditionCheckActivity(); + + // Agent index 1 has no condition mapped + WorkflowActivityContext ctx = mock(WorkflowActivityContext.class); + when(ctx.getInput(ConditionCheckInput.class)) + .thenReturn(new ConditionCheckInput(planner.getPlannerId(), 1)); + + assertThat(activity.run(ctx)).isEqualTo(true); + } + + @Test + void conditionCheckActivityShouldThrowForUnknownPlanner() { + ConditionCheckActivity activity = new ConditionCheckActivity(); + + WorkflowActivityContext ctx = mock(WorkflowActivityContext.class); + when(ctx.getInput(ConditionCheckInput.class)) + .thenReturn(new ConditionCheckInput("nonexistent", 0)); + + assertThatThrownBy(() -> activity.run(ctx)) + .isInstanceOf(IllegalStateException.class); + } +} \ No newline at end of file diff --git a/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/InputRecordsTest.java b/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/InputRecordsTest.java new file mode 100644 index 0000000000..042f001735 --- /dev/null +++ b/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/InputRecordsTest.java @@ -0,0 +1,51 @@ +package io.quarkiverse.dapr.langchain4j.workflow.orchestration; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class InputRecordsTest { + + @Test + void agentExecInputShouldStoreFields() { + AgentExecInput input = new AgentExecInput("planner-abc", 2, "planner-abc:2"); + + assertThat(input.plannerId()).isEqualTo("planner-abc"); + assertThat(input.agentIndex()).isEqualTo(2); + assertThat(input.agentRunId()).isEqualTo("planner-abc:2"); + } + + @Test + void agentExecInputShouldSupportEquality() { + AgentExecInput a = new AgentExecInput("id", 1, "id:1"); + AgentExecInput b = new AgentExecInput("id", 1, "id:1"); + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + void conditionCheckInputShouldStoreFields() { + ConditionCheckInput input = new ConditionCheckInput("planner-xyz", 5); + + assertThat(input.plannerId()).isEqualTo("planner-xyz"); + assertThat(input.agentIndex()).isEqualTo(5); + } + + @Test + void exitConditionCheckInputShouldStoreFields() { + ExitConditionCheckInput input = new ExitConditionCheckInput("planner-loop", 7); + + assertThat(input.plannerId()).isEqualTo("planner-loop"); + assertThat(input.iteration()).isEqualTo(7); + } + + @Test + void differentRecordTypesShouldNotBeEqual() { + AgentExecInput agent = new AgentExecInput("id", 1, "id:1"); + ConditionCheckInput condition = new ConditionCheckInput("id", 1); + + // They're different record types, so they shouldn't be equal + assertThat(agent).isNotEqualTo(condition); + } +} diff --git a/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/OrchestrationInputTest.java b/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/OrchestrationInputTest.java new file mode 100644 index 0000000000..7a0dfb8791 --- /dev/null +++ b/quarkus/runtime/src/test/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/OrchestrationInputTest.java @@ -0,0 +1,35 @@ +package io.quarkiverse.dapr.langchain4j.workflow.orchestration; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class OrchestrationInputTest { + + @Test + void shouldStoreAllFields() { + OrchestrationInput input = new OrchestrationInput("planner-1", 3, 10, true); + + assertThat(input.plannerId()).isEqualTo("planner-1"); + assertThat(input.agentCount()).isEqualTo(3); + assertThat(input.maxIterations()).isEqualTo(10); + assertThat(input.testExitAtLoopEnd()).isTrue(); + } + + @Test + void shouldSupportEquality() { + OrchestrationInput a = new OrchestrationInput("id", 2, 5, false); + OrchestrationInput b = new OrchestrationInput("id", 2, 5, false); + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + void shouldDetectInequality() { + OrchestrationInput a = new OrchestrationInput("id1", 2, 5, false); + OrchestrationInput b = new OrchestrationInput("id2", 2, 5, false); + + assertThat(a).isNotEqualTo(b); + } +} diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml index 98645d005f..d8237ca92a 100644 --- a/spotbugs-exclude.xml +++ b/spotbugs-exclude.xml @@ -63,4 +63,18 @@ + + + + + + + + + + + + + + \ No newline at end of file From 920b13e2aef4e36abfded6269c3b9fecec0846fd Mon Sep 17 00:00:00 2001 From: Javier Aliaga Date: Thu, 16 Apr 2026 12:44:57 +0200 Subject: [PATCH 02/21] feat(registry): add agent registry and dapr-agents compatibility Add team index for dapr-agents format, register composite agents with their LangChain4j type, move packages from io.quarkiverse.dapr to io.dapr.quarkus, add independent CI build/release workflows, and decouple quarkus version from SDK for independent releases. NEWMSG "$@" Signed-off-by: Javier Aliaga --- .github/workflows/build.yml | 4 + .github/workflows/quarkus-build.yml | 82 +++++++ .github/workflows/quarkus-release.yml | 55 +++++ .github/workflows/validate-docs.yml | 4 + .github/workflows/validate.yml | 4 + quarkus/deployment/pom.xml | 2 +- .../deployment/DaprAgenticProcessor.java | 219 ++++++++++++++---- quarkus/examples/pom.xml | 2 +- .../quarkus}/examples/CreativeWriter.java | 2 +- .../quarkus}/examples/ParallelCreator.java | 2 +- .../quarkus}/examples/ParallelResource.java | 2 +- .../quarkus}/examples/ParallelStatus.java | 2 +- .../quarkus}/examples/ResearchResource.java | 2 +- .../quarkus}/examples/ResearchTools.java | 2 +- .../quarkus}/examples/ResearchWriter.java | 2 +- .../quarkus}/examples/StoryCreator.java | 2 +- .../quarkus}/examples/StoryResource.java | 2 +- .../quarkus}/examples/StyleEditor.java | 2 +- .../src/main/resources/application.properties | 2 +- .../examples/DaprWorkflowClientTest.java | 2 +- .../examples/DockerAvailableCondition.java | 2 +- .../quarkus}/examples/MockChatModel.java | 2 +- .../examples/ParallelResourceTest.java | 2 +- .../quarkus}/examples/StoryResourceTest.java | 2 +- quarkus/pom.xml | 12 +- .../pom.xml | 2 +- .../agents/registry/model/AgentMetadata.java | 2 +- .../registry/model/AgentMetadataSchema.java | 2 +- .../agents/registry/model/LlmMetadata.java | 2 +- .../agents/registry/model/MemoryMetadata.java | 2 +- .../agents/registry/model/PubSubMetadata.java | 2 +- .../registry/model/RegistryMetadata.java | 2 +- .../agents/registry/model/ToolMetadata.java | 2 +- .../registry/service/AgentRegistry.java | 165 ++++++++++++- .../model/AgentMetadataSchemaTest.java | 2 +- .../service/AgentRegistryDevServicesTest.java | 27 ++- .../registry/service/AgentRegistryTest.java | 4 +- .../registry/service/MockChatModel.java | 2 +- .../agents/registry/service/TestAgent.java | 2 +- .../registry/service/TestAgentBean.java | 2 +- quarkus/runtime/pom.xml | 2 +- .../langchain4j/agent/AgentNameRegistry.java | 80 +++++++ .../langchain4j/agent/AgentRunContext.java | 4 +- .../agent/AgentRunLifecycleManager.java | 27 ++- .../agent/DaprAgentContextHolder.java | 4 +- .../agent/DaprAgentInterceptorBinding.java | 4 +- .../agent/DaprAgentMetadataHolder.java | 2 +- .../agent/DaprAgentMethodInterceptor.java | 11 +- .../agent/DaprAgentRunRegistry.java | 6 +- .../DaprAgentToolInterceptorBinding.java | 2 +- .../agent/DaprChatModelDecorator.java | 34 +-- .../agent/DaprChatModelWrapper.java | 72 ++++++ .../agent/DaprToolCallInterceptor.java | 8 +- .../agent/activities/LlmCallActivity.java | 26 +-- .../agent/activities/LlmCallInput.java | 6 +- .../agent/activities/LlmCallOutput.java | 4 +- .../agent/activities/ToolCallActivity.java | 12 +- .../agent/activities/ToolCallInput.java | 6 +- .../agent/activities/ToolCallOutput.java | 2 +- .../agent/workflow/AgentEvent.java | 4 +- .../agent/workflow/AgentRunInput.java | 4 +- .../agent/workflow/AgentRunOutput.java | 6 +- .../agent/workflow/AgentRunWorkflow.java | 53 +++-- .../memory/KeyValueChatMemoryStore.java | 2 +- .../workflow/DaprAgentService.java | 2 +- .../workflow/DaprAgentServiceUtil.java | 95 ++++++++ .../workflow/DaprConditionalAgentService.java | 27 ++- .../workflow/DaprLoopAgentService.java | 27 ++- .../workflow/DaprParallelAgentService.java | 27 ++- .../workflow/DaprPlannerRegistry.java | 2 +- .../workflow/DaprSequentialAgentService.java | 19 +- .../workflow/DaprWorkflowAgentsBuilder.java | 2 +- .../workflow/DaprWorkflowPlanner.java | 23 +- .../workflow/DaprWorkflowRuntimeRecorder.java | 110 +++++++++ .../workflow/WorkflowNameResolver.java | 2 +- .../orchestration/AgentExecInput.java | 2 +- .../orchestration/ConditionCheckInput.java | 2 +- .../ConditionalOrchestrationWorkflow.java | 14 +- .../ExitConditionCheckInput.java | 2 +- .../LoopOrchestrationWorkflow.java | 14 +- .../orchestration/OrchestrationInput.java | 2 +- .../ParallelOrchestrationWorkflow.java | 16 +- .../SequentialOrchestrationWorkflow.java | 17 +- .../activities/AgentExecutionActivity.java | 18 +- .../activities/ConditionCheckActivity.java | 8 +- .../ExitConditionCheckActivity.java | 8 +- .../workflow/DaprAgentServiceUtil.java | 38 --- ...n4j.agentic.workflow.WorkflowAgentsBuilder | 2 +- .../memory/KeyValueChatMemoryStoreTest.java | 2 +- .../workflow/DaprAgentServiceUtilTest.java | 2 +- .../workflow/DaprPlannerRegistryTest.java | 4 +- .../DaprWorkflowAgentsBuilderTest.java | 2 +- .../workflow/DaprWorkflowPlannerTest.java | 6 +- .../orchestration/ActivitiesTest.java | 12 +- .../orchestration/InputRecordsTest.java | 2 +- .../orchestration/OrchestrationInputTest.java | 2 +- 96 files changed, 1196 insertions(+), 332 deletions(-) create mode 100644 .github/workflows/quarkus-build.yml create mode 100644 .github/workflows/quarkus-release.yml rename quarkus/deployment/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/deployment/DaprAgenticProcessor.java (74%) rename quarkus/examples/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/examples/CreativeWriter.java (97%) rename quarkus/examples/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/examples/ParallelCreator.java (98%) rename quarkus/examples/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/examples/ParallelResource.java (97%) rename quarkus/examples/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/examples/ParallelStatus.java (94%) rename quarkus/examples/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/examples/ResearchResource.java (97%) rename quarkus/examples/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/examples/ResearchTools.java (98%) rename quarkus/examples/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/examples/ResearchWriter.java (98%) rename quarkus/examples/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/examples/StoryCreator.java (96%) rename quarkus/examples/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/examples/StoryResource.java (97%) rename quarkus/examples/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/examples/StyleEditor.java (97%) rename quarkus/examples/src/test/java/io/{quarkiverse/dapr => dapr/quarkus}/examples/DaprWorkflowClientTest.java (95%) rename quarkus/examples/src/test/java/io/{quarkiverse/dapr => dapr/quarkus}/examples/DockerAvailableCondition.java (98%) rename quarkus/examples/src/test/java/io/{quarkiverse/dapr => dapr/quarkus}/examples/MockChatModel.java (96%) rename quarkus/examples/src/test/java/io/{quarkiverse/dapr => dapr/quarkus}/examples/ParallelResourceTest.java (97%) rename quarkus/examples/src/test/java/io/{quarkiverse/dapr => dapr/quarkus}/examples/StoryResourceTest.java (97%) rename quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/agents/registry/model/AgentMetadata.java (98%) rename quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/agents/registry/model/AgentMetadataSchema.java (99%) rename quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/agents/registry/model/LlmMetadata.java (98%) rename quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/agents/registry/model/MemoryMetadata.java (97%) rename quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/agents/registry/model/PubSubMetadata.java (97%) rename quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/agents/registry/model/RegistryMetadata.java (96%) rename quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/agents/registry/model/ToolMetadata.java (97%) rename quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/agents/registry/service/AgentRegistry.java (60%) rename quarkus/quarkus-agentic-dapr-agents-registry/src/test/java/io/{quarkiverse/dapr => dapr/quarkus}/agents/registry/model/AgentMetadataSchemaTest.java (99%) rename quarkus/quarkus-agentic-dapr-agents-registry/src/test/java/io/{quarkiverse/dapr => dapr/quarkus}/agents/registry/service/AgentRegistryDevServicesTest.java (82%) rename quarkus/quarkus-agentic-dapr-agents-registry/src/test/java/io/{quarkiverse/dapr => dapr/quarkus}/agents/registry/service/AgentRegistryTest.java (98%) rename quarkus/quarkus-agentic-dapr-agents-registry/src/test/java/io/{quarkiverse/dapr => dapr/quarkus}/agents/registry/service/MockChatModel.java (95%) rename quarkus/quarkus-agentic-dapr-agents-registry/src/test/java/io/{quarkiverse/dapr => dapr/quarkus}/agents/registry/service/TestAgent.java (94%) rename quarkus/quarkus-agentic-dapr-agents-registry/src/test/java/io/{quarkiverse/dapr => dapr/quarkus}/agents/registry/service/TestAgentBean.java (92%) create mode 100644 quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/AgentNameRegistry.java rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/agent/AgentRunContext.java (97%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/agent/AgentRunLifecycleManager.java (84%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/agent/DaprAgentContextHolder.java (91%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/agent/DaprAgentInterceptorBinding.java (92%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/agent/DaprAgentMetadataHolder.java (97%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/agent/DaprAgentMethodInterceptor.java (93%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/agent/DaprAgentRunRegistry.java (90%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/agent/DaprAgentToolInterceptorBinding.java (96%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/agent/DaprChatModelDecorator.java (91%) create mode 100644 quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelWrapper.java rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/agent/DaprToolCallInterceptor.java (95%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/agent/activities/LlmCallActivity.java (84%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/agent/activities/LlmCallInput.java (85%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/agent/activities/LlmCallOutput.java (90%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/agent/activities/ToolCallActivity.java (91%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/agent/activities/ToolCallInput.java (82%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/agent/activities/ToolCallOutput.java (95%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/agent/workflow/AgentEvent.java (89%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/agent/workflow/AgentRunInput.java (91%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/agent/workflow/AgentRunOutput.java (90%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/agent/workflow/AgentRunWorkflow.java (66%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/memory/KeyValueChatMemoryStore.java (98%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/DaprAgentService.java (95%) create mode 100644 quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/DaprAgentServiceUtil.java rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/DaprConditionalAgentService.java (85%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/DaprLoopAgentService.java (85%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/DaprParallelAgentService.java (77%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/DaprPlannerRegistry.java (97%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/DaprSequentialAgentService.java (84%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/DaprWorkflowAgentsBuilder.java (98%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/DaprWorkflowPlanner.java (94%) create mode 100644 quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowRuntimeRecorder.java rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/WorkflowNameResolver.java (96%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/orchestration/AgentExecInput.java (94%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/orchestration/ConditionCheckInput.java (93%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/orchestration/ConditionalOrchestrationWorkflow.java (82%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/orchestration/ExitConditionCheckInput.java (93%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/orchestration/LoopOrchestrationWorkflow.java (85%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/orchestration/OrchestrationInput.java (94%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/orchestration/ParallelOrchestrationWorkflow.java (82%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/orchestration/SequentialOrchestrationWorkflow.java (78%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/orchestration/activities/AgentExecutionActivity.java (82%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/orchestration/activities/ConditionCheckActivity.java (83%) rename quarkus/runtime/src/main/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/orchestration/activities/ExitConditionCheckActivity.java (83%) delete mode 100644 quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprAgentServiceUtil.java rename quarkus/runtime/src/test/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/memory/KeyValueChatMemoryStoreTest.java (99%) rename quarkus/runtime/src/test/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/DaprAgentServiceUtilTest.java (95%) rename quarkus/runtime/src/test/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/DaprPlannerRegistryTest.java (91%) rename quarkus/runtime/src/test/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/DaprWorkflowAgentsBuilderTest.java (98%) rename quarkus/runtime/src/test/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/DaprWorkflowPlannerTest.java (97%) rename quarkus/runtime/src/test/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/orchestration/ActivitiesTest.java (92%) rename quarkus/runtime/src/test/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/orchestration/InputRecordsTest.java (96%) rename quarkus/runtime/src/test/java/io/{quarkiverse/dapr => dapr/quarkus}/langchain4j/workflow/orchestration/OrchestrationInputTest.java (94%) 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..0b86c6259c --- /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 }} + run: | + set -ue + REL_VERSION="${{ inputs.rel_version }}" + + 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/quarkus/deployment/pom.xml b/quarkus/deployment/pom.xml index bb75c756d0..7ca3506e74 100644 --- a/quarkus/deployment/pom.xml +++ b/quarkus/deployment/pom.xml @@ -6,7 +6,7 @@ io.dapr.quarkus dapr-quarkus-agentic-parent - 1.18.0-SNAPSHOT + 0.1.0-SNAPSHOT ../pom.xml diff --git a/quarkus/deployment/src/main/java/io/quarkiverse/dapr/langchain4j/deployment/DaprAgenticProcessor.java b/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java similarity index 74% rename from quarkus/deployment/src/main/java/io/quarkiverse/dapr/langchain4j/deployment/DaprAgenticProcessor.java rename to quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java index 689a6fd819..7487b5d61c 100644 --- a/quarkus/deployment/src/main/java/io/quarkiverse/dapr/langchain4j/deployment/DaprAgenticProcessor.java +++ b/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java @@ -11,11 +11,11 @@ limitations under the License. */ -package io.quarkiverse.dapr.langchain4j.deployment; +package io.dapr.quarkus.langchain4j.deployment; -import io.quarkiverse.dapr.deployment.items.WorkflowItemBuildItem; -import io.quarkiverse.dapr.langchain4j.agent.AgentRunLifecycleManager; -import io.quarkiverse.dapr.langchain4j.agent.DaprAgentMetadataHolder; +import io.dapr.quarkus.langchain4j.agent.AgentRunLifecycleManager; +import io.dapr.quarkus.langchain4j.agent.DaprAgentMetadataHolder; +import io.dapr.quarkus.langchain4j.workflow.DaprWorkflowRuntimeRecorder; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.AnnotationsTransformerBuildItem; import io.quarkus.arc.deployment.GeneratedBeanBuildItem; @@ -23,6 +23,8 @@ import io.quarkus.arc.processor.AnnotationsTransformer; 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; @@ -35,6 +37,7 @@ import io.quarkus.gizmo.MethodDescriptor; import io.quarkus.gizmo.ResultHandle; import io.quarkus.gizmo.TryBlock; +import io.quarkus.runtime.RuntimeValue; import jakarta.annotation.Priority; import jakarta.decorator.Decorator; import jakarta.decorator.Delegate; @@ -54,6 +57,7 @@ import java.lang.reflect.Modifier; import java.util.HashSet; +import java.util.Map; import java.util.Set; /** @@ -89,7 +93,7 @@ public class DaprAgenticProcessor { /** * Generated decorator classes live in this package to avoid polluting user packages. */ - private static final String DECORATOR_PACKAGE = "io.quarkiverse.dapr.langchain4j.generated"; + private static final String DECORATOR_PACKAGE = "io.dapr.quarkus.langchain4j.generated"; /** * LangChain4j {@code @Tool} annotation (on CDI bean methods). @@ -119,13 +123,13 @@ public class DaprAgenticProcessor { * Our interceptor binding that triggers {@code DaprToolCallInterceptor}. */ private static final DotName DAPR_TOOL_INTERCEPTOR_BINDING = DotName.createSimple( - "io.quarkiverse.dapr.langchain4j.agent.DaprAgentToolInterceptorBinding"); + "io.dapr.quarkus.langchain4j.agent.DaprAgentToolInterceptorBinding"); /** * Our interceptor binding that triggers {@code DaprAgentMethodInterceptor}. */ private static final DotName DAPR_AGENT_INTERCEPTOR_BINDING = DotName.createSimple( - "io.quarkiverse.dapr.langchain4j.agent.DaprAgentInterceptorBinding"); + "io.dapr.quarkus.langchain4j.agent.DaprAgentInterceptorBinding"); /** * {@code @WorkflowMetadata} annotation for custom workflow registration names. @@ -139,23 +143,43 @@ public class DaprAgenticProcessor { private static final DotName ACTIVITY_METADATA_DOTNAME = DotName.createSimple( "io.quarkiverse.dapr.workflows.ActivityMetadata"); + // Composite agent annotations → workflow class mapping + 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 String ORCH_PKG = + "io.dapr.quarkus.langchain4j.workflow.orchestration."; + + private static final Map AGENT_ANNOTATION_TO_WORKFLOW = Map.of( + SEQUENCE_AGENT_ANNOTATION, ORCH_PKG + "SequentialOrchestrationWorkflow", + PARALLEL_AGENT_ANNOTATION, ORCH_PKG + "ParallelOrchestrationWorkflow", + LOOP_AGENT_ANNOTATION, ORCH_PKG + "LoopOrchestrationWorkflow", + CONDITIONAL_AGENT_ANNOTATION, ORCH_PKG + "ConditionalOrchestrationWorkflow" + ); + private static final String[] WORKFLOW_CLASSES = { - "io.quarkiverse.dapr.langchain4j.workflow.orchestration.SequentialOrchestrationWorkflow", - "io.quarkiverse.dapr.langchain4j.workflow.orchestration.ParallelOrchestrationWorkflow", - "io.quarkiverse.dapr.langchain4j.workflow.orchestration.LoopOrchestrationWorkflow", - "io.quarkiverse.dapr.langchain4j.workflow.orchestration.ConditionalOrchestrationWorkflow", + "io.dapr.quarkus.langchain4j.workflow.orchestration.SequentialOrchestrationWorkflow", + "io.dapr.quarkus.langchain4j.workflow.orchestration.ParallelOrchestrationWorkflow", + "io.dapr.quarkus.langchain4j.workflow.orchestration.LoopOrchestrationWorkflow", + "io.dapr.quarkus.langchain4j.workflow.orchestration.ConditionalOrchestrationWorkflow", // Per-agent workflow (one per @Agent invocation) - "io.quarkiverse.dapr.langchain4j.agent.workflow.AgentRunWorkflow", + "io.dapr.quarkus.langchain4j.agent.workflow.AgentRunWorkflow", }; private static final String[] ACTIVITY_CLASSES = { - "io.quarkiverse.dapr.langchain4j.workflow.orchestration.activities.AgentExecutionActivity", - "io.quarkiverse.dapr.langchain4j.workflow.orchestration.activities.ExitConditionCheckActivity", - "io.quarkiverse.dapr.langchain4j.workflow.orchestration.activities.ConditionCheckActivity", + "io.dapr.quarkus.langchain4j.workflow.orchestration.activities.AgentExecutionActivity", + "io.dapr.quarkus.langchain4j.workflow.orchestration.activities.ExitConditionCheckActivity", + "io.dapr.quarkus.langchain4j.workflow.orchestration.activities.ConditionCheckActivity", // Per-tool-call activity - "io.quarkiverse.dapr.langchain4j.agent.activities.ToolCallActivity", + "io.dapr.quarkus.langchain4j.agent.activities.ToolCallActivity", // Per-LLM-call activity - "io.quarkiverse.dapr.langchain4j.agent.activities.LlmCallActivity", + "io.dapr.quarkus.langchain4j.agent.activities.LlmCallActivity", }; @BuildStep @@ -173,46 +197,123 @@ IndexDependencyBuildItem indexRuntimeModule() { } /** - * Produce {@link WorkflowItemBuildItem} for each of our Workflow and WorkflowActivity - * classes. + * Register all workflows and activities using the Dapr Java SDK directly, + * bypassing quarkus-dapr's {@code WorkflowItemBuildItem} pipeline. + * + *

This gives full control over workflow naming: the same class can be + * registered under multiple names (e.g., {@code AgentRunWorkflow} as both + * {@code dapr.langchain4j.AgentRun.workflow} and + * {@code dapr.langchain4j.WeatherAssistant.workflow}). */ @BuildStep - void registerWorkflowsAndActivities(CombinedIndexBuildItem combinedIndex, - BuildProducer workflowItems) { + @Record(ExecutionTime.RUNTIME_INIT) + void setupWorkflowRuntime(DaprWorkflowRuntimeRecorder recorder, + CombinedIndexBuildItem combinedIndex) { + + @SuppressWarnings("rawtypes") RuntimeValue builder = recorder.createBuilder(); IndexView index = combinedIndex.getIndex(); + // Register generic workflows (from @WorkflowMetadata name) for (String className : WORKFLOW_CLASSES) { ClassInfo classInfo = index.getClassByName(DotName.createSimple(className)); - if (classInfo != null) { - String regName = null; - String version = null; - Boolean isLatest = null; - AnnotationInstance meta = classInfo.annotation(WORKFLOW_METADATA_DOTNAME); - if (meta != null) { - regName = stringValueOrNull(meta, "name"); - version = stringValueOrNull(meta, "version"); - AnnotationValue isLatestVal = meta.value("isLatest"); - if (isLatestVal != null) { - isLatest = isLatestVal.asBoolean(); - } + 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; } - workflowItems.produce(new WorkflowItemBuildItem( - classInfo, WorkflowItemBuildItem.Type.WORKFLOW, regName, version, isLatest)); } + recorder.registerWorkflow(builder, regName, className); } + // Register activities (from @ActivityMetadata name) for (String className : ACTIVITY_CLASSES) { ClassInfo classInfo = index.getClassByName(DotName.createSimple(className)); - if (classInfo != null) { - String regName = null; - AnnotationInstance meta = classInfo.annotation(ACTIVITY_METADATA_DOTNAME); - if (meta != null) { - regName = stringValueOrNull(meta, "name"); + 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-specific workflow names for composite agents + for (Map.Entry entry : AGENT_ANNOTATION_TO_WORKFLOW.entrySet()) { + DotName annotationName = entry.getKey(); + String workflowClassName = entry.getValue(); + for (AnnotationInstance ann : index.getAnnotations(annotationName)) { + AnnotationValue nameValue = ann.value("name"); + if (nameValue == null || nameValue.asString().isEmpty()) { + continue; + } + String agentName = nameValue.asString(); + String workflowName = "dapr.langchain4j." + + toTitleCase(agentName) + ".workflow"; + LOG.infof("Registering workflow '%s' for @%s(name=\"%s\")", + workflowName, annotationName.local(), agentName); + recorder.registerWorkflow(builder, workflowName, workflowClassName); + } + } + + // Register agent-specific workflow names for standalone @Agent + String agentRunClass = + "io.dapr.quarkus.langchain4j.agent.workflow.AgentRunWorkflow"; + 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(); + String interfaceName = ann.target().asMethod() + .declaringClass().name().toString(); + String workflowName = "dapr.langchain4j." + + toTitleCase(agentName) + ".workflow"; + LOG.infof("Registering workflow '%s' for @Agent(name=\"%s\")", + workflowName, agentName); + recorder.registerWorkflow(builder, workflowName, agentRunClass); + recorder.registerAgentName(interfaceName, agentName); + } + + // Register AgentRunWorkflow under *.agent-run names for ALL agents + // (both standalone and composite). Used by orchestration workflows for + // child workflows — avoids conflicts with orchestration *.workflow names. + for (Map.Entry entry : AGENT_ANNOTATION_TO_WORKFLOW.entrySet()) { + for (AnnotationInstance ann : index.getAnnotations(entry.getKey())) { + AnnotationValue nameValue = ann.value("name"); + if (nameValue == null || nameValue.asString().isEmpty()) { + continue; } - workflowItems.produce(new WorkflowItemBuildItem( - classInfo, WorkflowItemBuildItem.Type.WORKFLOW_ACTIVITY, regName, null, null)); + String runName = "dapr.langchain4j." + + toTitleCase(nameValue.asString()) + ".agent-run"; + recorder.registerWorkflow(builder, runName, agentRunClass); } } + 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()) { + String runName = "dapr.langchain4j." + + toTitleCase(nameValue.asString()) + ".agent-run"; + recorder.registerWorkflow(builder, runName, agentRunClass); + } + } + + recorder.startRuntime(builder); } /** @@ -231,15 +332,15 @@ void registerAdditionalBeans(BuildProducer additionalBe AgentRunLifecycleManager.class.getName())); // CDI interceptors must be registered as unremovable beans. additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf( - "io.quarkiverse.dapr.langchain4j.agent.DaprToolCallInterceptor")); + "io.dapr.quarkus.langchain4j.agent.DaprToolCallInterceptor")); additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf( - "io.quarkiverse.dapr.langchain4j.agent.DaprAgentMethodInterceptor")); - // CDI decorator that wraps ChatModel to route LLM calls through Dapr activities. - // A decorator is used instead of an interceptor because quarkus-langchain4j registers - // ChatModel as a synthetic bean, and Arc does not apply CDI interceptors to synthetic - // beans via AnnotationsTransformer -- but it DOES apply decorators at the type level. + "io.dapr.quarkus.langchain4j.agent.DaprAgentMethodInterceptor")); + // @Alternative ChatModel wrapper + CDI decorator for routing LLM calls through Dapr. + // The wrapper provides a non-synthetic bean that the decorator can decorate. additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf( - "io.quarkiverse.dapr.langchain4j.agent.DaprChatModelDecorator")); + "io.dapr.quarkus.langchain4j.agent.DaprChatModelWrapper")); + additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf( + "io.dapr.quarkus.langchain4j.agent.DaprChatModelDecorator")); } /** @@ -253,7 +354,7 @@ void registerAdditionalBeans(BuildProducer additionalBe * decorators, however, are matched at the bean type level and are applied * by Arc to all beans (including synthetic beans) whose type includes the delegate type. * This is the same mechanism used by - * {@link io.quarkiverse.dapr.langchain4j.agent.DaprChatModelDecorator} + * {@link io.dapr.quarkus.langchain4j.agent.DaprChatModelDecorator} * to wrap the synthetic {@code ChatModel} bean. * *

What the generated decorator does

@@ -581,6 +682,22 @@ private String extractAnnotationText(MethodInfo method, DotName annotationName) // Annotation metadata extraction helpers // ------------------------------------------------------------------------- + private static String toTitleCase(String name) { + StringBuilder sb = new StringBuilder(); + boolean capitalizeNext = true; + for (char c : name.toCharArray()) { + if (c == '-' || c == '_' || c == ' ') { + capitalizeNext = true; + } else if (capitalizeNext) { + sb.append(Character.toUpperCase(c)); + capitalizeNext = false; + } else { + sb.append(c); + } + } + return sb.toString(); + } + private static String stringValueOrNull(AnnotationInstance annotation, String name) { AnnotationValue value = annotation.value(name); if (value == null) { @@ -611,7 +728,7 @@ AnnotationsTransformerBuildItem addDaprInterceptorToToolMethods() { * Automatically apply {@code @DaprAgentInterceptorBinding} to every * {@code @Agent}-annotated method in the application index. * - *

This causes {@link io.quarkiverse.dapr.langchain4j.agent.DaprAgentMethodInterceptor} + *

This causes {@link io.dapr.quarkus.langchain4j.agent.DaprAgentMethodInterceptor} * to fire when an {@code @Agent} method is called on a regular CDI bean (not a synthetic * AiService bean). For synthetic AiService beans the generated CDI decorator (produced by * {@link #generateAgentDecorators}) is the authoritative hook point. diff --git a/quarkus/examples/pom.xml b/quarkus/examples/pom.xml index 2e8ac26391..7db01a61d1 100644 --- a/quarkus/examples/pom.xml +++ b/quarkus/examples/pom.xml @@ -6,7 +6,7 @@ io.dapr.quarkus dapr-quarkus-agentic-parent - 1.18.0-SNAPSHOT + 0.1.0-SNAPSHOT ../pom.xml diff --git a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/CreativeWriter.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/CreativeWriter.java similarity index 97% rename from quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/CreativeWriter.java rename to quarkus/examples/src/main/java/io/dapr/quarkus/examples/CreativeWriter.java index ec67d9a470..3ef7157986 100644 --- a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/CreativeWriter.java +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/CreativeWriter.java @@ -11,7 +11,7 @@ limitations under the License. */ -package io.quarkiverse.dapr.examples; +package io.dapr.quarkus.examples; import dev.langchain4j.agentic.Agent; import dev.langchain4j.service.UserMessage; diff --git a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelCreator.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ParallelCreator.java similarity index 98% rename from quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelCreator.java rename to quarkus/examples/src/main/java/io/dapr/quarkus/examples/ParallelCreator.java index 30277c3292..16648cdbd4 100644 --- a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelCreator.java +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ParallelCreator.java @@ -11,7 +11,7 @@ limitations under the License. */ -package io.quarkiverse.dapr.examples; +package io.dapr.quarkus.examples; import dev.langchain4j.agentic.declarative.Output; import dev.langchain4j.agentic.declarative.ParallelAgent; diff --git a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelResource.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ParallelResource.java similarity index 97% rename from quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelResource.java rename to quarkus/examples/src/main/java/io/dapr/quarkus/examples/ParallelResource.java index 52a44879b5..b520705f11 100644 --- a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelResource.java +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ParallelResource.java @@ -11,7 +11,7 @@ limitations under the License. */ -package io.quarkiverse.dapr.examples; +package io.dapr.quarkus.examples; import jakarta.inject.Inject; import jakarta.ws.rs.DefaultValue; diff --git a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelStatus.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ParallelStatus.java similarity index 94% rename from quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelStatus.java rename to quarkus/examples/src/main/java/io/dapr/quarkus/examples/ParallelStatus.java index f4737d3b34..e01a5e0f19 100644 --- a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelStatus.java +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ParallelStatus.java @@ -11,7 +11,7 @@ limitations under the License. */ -package io.quarkiverse.dapr.examples; +package io.dapr.quarkus.examples; public record ParallelStatus(String status, String story, String summary) { } diff --git a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchResource.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ResearchResource.java similarity index 97% rename from quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchResource.java rename to quarkus/examples/src/main/java/io/dapr/quarkus/examples/ResearchResource.java index db3e5e4e62..5521643ad6 100644 --- a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchResource.java +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ResearchResource.java @@ -11,7 +11,7 @@ limitations under the License. */ -package io.quarkiverse.dapr.examples; +package io.dapr.quarkus.examples; import jakarta.inject.Inject; import jakarta.ws.rs.DefaultValue; diff --git a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchTools.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ResearchTools.java similarity index 98% rename from quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchTools.java rename to quarkus/examples/src/main/java/io/dapr/quarkus/examples/ResearchTools.java index 9af9e29187..b89c4ff02d 100644 --- a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchTools.java +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ResearchTools.java @@ -11,7 +11,7 @@ limitations under the License. */ -package io.quarkiverse.dapr.examples; +package io.dapr.quarkus.examples; import dev.langchain4j.agent.tool.Tool; import jakarta.enterprise.context.ApplicationScoped; diff --git a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchWriter.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ResearchWriter.java similarity index 98% rename from quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchWriter.java rename to quarkus/examples/src/main/java/io/dapr/quarkus/examples/ResearchWriter.java index aa6f2c183d..62aeb16162 100644 --- a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchWriter.java +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/ResearchWriter.java @@ -11,7 +11,7 @@ limitations under the License. */ -package io.quarkiverse.dapr.examples; +package io.dapr.quarkus.examples; import dev.langchain4j.agentic.Agent; import dev.langchain4j.service.UserMessage; diff --git a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StoryCreator.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/StoryCreator.java similarity index 96% rename from quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StoryCreator.java rename to quarkus/examples/src/main/java/io/dapr/quarkus/examples/StoryCreator.java index 26b6dd41eb..f7ea1ad513 100644 --- a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StoryCreator.java +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/StoryCreator.java @@ -11,7 +11,7 @@ limitations under the License. */ -package io.quarkiverse.dapr.examples; +package io.dapr.quarkus.examples; import dev.langchain4j.agentic.declarative.SequenceAgent; import dev.langchain4j.service.V; diff --git a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StoryResource.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/StoryResource.java similarity index 97% rename from quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StoryResource.java rename to quarkus/examples/src/main/java/io/dapr/quarkus/examples/StoryResource.java index afabf11f56..00aa063462 100644 --- a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StoryResource.java +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/StoryResource.java @@ -11,7 +11,7 @@ limitations under the License. */ -package io.quarkiverse.dapr.examples; +package io.dapr.quarkus.examples; import jakarta.inject.Inject; import jakarta.ws.rs.DefaultValue; diff --git a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StyleEditor.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/StyleEditor.java similarity index 97% rename from quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StyleEditor.java rename to quarkus/examples/src/main/java/io/dapr/quarkus/examples/StyleEditor.java index 871c88d039..3d1e67db95 100644 --- a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StyleEditor.java +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/StyleEditor.java @@ -11,7 +11,7 @@ limitations under the License. */ -package io.quarkiverse.dapr.examples; +package io.dapr.quarkus.examples; import dev.langchain4j.agentic.Agent; import dev.langchain4j.service.UserMessage; diff --git a/quarkus/examples/src/main/resources/application.properties b/quarkus/examples/src/main/resources/application.properties index a1c050062d..8ceffbc59c 100644 --- a/quarkus/examples/src/main/resources/application.properties +++ b/quarkus/examples/src/main/resources/application.properties @@ -6,7 +6,7 @@ quarkus.dapr.workflow.enabled=true quarkus.langchain4j.openai.api-key=${OPENAI_API_KEY:demo} quarkus.langchain4j.openai.chat-model.model-name=gpt-4o-mini -quarkus.log.category."io.quarkiverse.dapr.agents.registry".level=DEBUG +quarkus.log.category."io.dapr.quarkus.agents.registry".level=DEBUG # OpenTelemetry Configuration quarkus.otel.propagators=tracecontext,baggage diff --git a/quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/DaprWorkflowClientTest.java b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/DaprWorkflowClientTest.java similarity index 95% rename from quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/DaprWorkflowClientTest.java rename to quarkus/examples/src/test/java/io/dapr/quarkus/examples/DaprWorkflowClientTest.java index 2281527106..2f527db893 100644 --- a/quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/DaprWorkflowClientTest.java +++ b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/DaprWorkflowClientTest.java @@ -1,4 +1,4 @@ -package io.quarkiverse.dapr.examples; +package io.dapr.quarkus.examples; import static org.junit.jupiter.api.Assertions.assertNotNull; diff --git a/quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/DockerAvailableCondition.java b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/DockerAvailableCondition.java similarity index 98% rename from quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/DockerAvailableCondition.java rename to quarkus/examples/src/test/java/io/dapr/quarkus/examples/DockerAvailableCondition.java index 0747891d12..db8c786ede 100644 --- a/quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/DockerAvailableCondition.java +++ b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/DockerAvailableCondition.java @@ -1,4 +1,4 @@ -package io.quarkiverse.dapr.examples; +package io.dapr.quarkus.examples; import java.io.File; diff --git a/quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/MockChatModel.java b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/MockChatModel.java similarity index 96% rename from quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/MockChatModel.java rename to quarkus/examples/src/test/java/io/dapr/quarkus/examples/MockChatModel.java index 2a0d4a2321..b23def8292 100644 --- a/quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/MockChatModel.java +++ b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/MockChatModel.java @@ -1,4 +1,4 @@ -package io.quarkiverse.dapr.examples; +package io.dapr.quarkus.examples; import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.model.chat.ChatModel; diff --git a/quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/ParallelResourceTest.java b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/ParallelResourceTest.java similarity index 97% rename from quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/ParallelResourceTest.java rename to quarkus/examples/src/test/java/io/dapr/quarkus/examples/ParallelResourceTest.java index 03ce2263e3..09a41d9e78 100644 --- a/quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/ParallelResourceTest.java +++ b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/ParallelResourceTest.java @@ -1,4 +1,4 @@ -package io.quarkiverse.dapr.examples; +package io.dapr.quarkus.examples; import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.equalTo; diff --git a/quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/StoryResourceTest.java b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/StoryResourceTest.java similarity index 97% rename from quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/StoryResourceTest.java rename to quarkus/examples/src/test/java/io/dapr/quarkus/examples/StoryResourceTest.java index d0d110bcea..5de3a60bd4 100644 --- a/quarkus/examples/src/test/java/io/quarkiverse/dapr/examples/StoryResourceTest.java +++ b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/StoryResourceTest.java @@ -1,4 +1,4 @@ -package io.quarkiverse.dapr.examples; +package io.dapr.quarkus.examples; import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.notNullValue; diff --git a/quarkus/pom.xml b/quarkus/pom.xml index c15a341e59..dd5e0a5ea2 100644 --- a/quarkus/pom.xml +++ b/quarkus/pom.xml @@ -12,9 +12,10 @@ io.dapr.quarkus dapr-quarkus-agentic-parent + 0.1.0-SNAPSHOT pom - Dapr Quarkus Agentic - Parent + Dapr Quarkus Agentic - Parent runtime @@ -30,6 +31,8 @@ 3.31.2 2.5.0 1.7.1 + + 1.18.0-SNAPSHOT @@ -41,6 +44,13 @@ pom import + + + io.dapr + dapr-sdk-workflows + ${dapr.sdk.version} + - - io.quarkiverse.langchain4j - quarkus-langchain4j-agentic - - - io.dapr.quarkus quarkus-agentic-dapr - 1.18.0-SNAPSHOT + 0.1.0-SNAPSHOT ``` -You also need an LLM provider and a REST endpoint: - -```xml - - io.quarkiverse.langchain4j - quarkus-langchain4j-openai - - - io.quarkus - quarkus-rest - -``` - -Optionally, add the agent registry to auto-register agent metadata in a Dapr state store: - -```xml - - io.dapr.quarkus - quarkus-agentic-dapr-agents-registry - 1.18.0-SNAPSHOT - -``` - -### Configuration - -Add to `application.properties`: +### 2. Configure ```properties -# Enable Dapr Dev Services (auto-starts sidecar, placement, scheduler, state store) -quarkus.dapr.devservices.enabled=true -quarkus.dapr.workflow.enabled=true +quarkus.dapr.devservices.enabled=false +quarkus.dapr.workflow.enabled=false -# LLM provider configuration -quarkus.langchain4j.openai.api-key=${OPENAI_API_KEY} -quarkus.langchain4j.openai.chat-model.model-name=gpt-4o-mini +# Dapr sidecar endpoints +dapr.grpc.endpoint=${DAPR_GRPC_ENDPOINT:http://localhost:40001} +dapr.http.endpoint=${DAPR_HTTP_ENDPOINT:http://localhost:3500} ``` -## Examples - -### 1. Sequential Agent — Story Creator - -A composite agent that runs two sub-agents in sequence: first a `CreativeWriter` generates a story draft, then a `StyleEditor` refines it. - -**Agent definitions:** +### 3. Write agents (standard LangChain4j — no Dapr-specific code) ```java -public interface CreativeWriter { +public interface WeatherAssistant { - @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); -} - -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); + @ToolBox(WeatherTools.class) + @UserMessage("Check the weather in {{city}}") + @Agent(name = "weather-assistant", outputKey = "weather") + String checkWeather(@V("city") String city); } ``` -**Orchestration:** - -```java -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); -} -``` - -**REST endpoint:** - -```java -@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); - } -} -``` - -Behind the scenes, this starts a `SequentialOrchestrationWorkflow` in Dapr that runs `CreativeWriter` first, passes the `story` output to `StyleEditor`, and returns the final result. - -**Try it:** +### 4. Run with Dapr ```bash -curl "http://localhost:8080/story?topic=dragons&style=comedy" -``` - -### 2. Parallel Agent — Story + Research - -A composite agent that runs a `StoryCreator` (itself a sequential agent) and a `ResearchWriter` in parallel. This demonstrates nested composite agents. - -```java -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); - - @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); - } -} -``` - -Behind the scenes, a `ParallelOrchestrationWorkflow` spawns both sub-agents concurrently using Dapr's `allOf()` task composition and waits for all to complete. - -**Try it:** - -```bash -curl "http://localhost:8080/parallel?topic=dragons&country=France&style=comedy" -``` - -### 3. Standalone Agent with Tool Calls — Research Writer - -An agent that uses `@Tool`-annotated methods to fetch data. Tool calls are automatically routed through `ToolCallActivity` Dapr activities. - -```java -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); -} -``` - -The tools are plain CDI beans — no Dapr-specific code needed: - -```java -@ApplicationScoped -public class ResearchTools { - - @Tool("Looks up real-time population data for a given country") - public String getPopulation(String country) { - return switch (country.toLowerCase()) { - case "france" -> "France has approximately 68 million inhabitants (2024)."; - case "germany" -> "Germany has approximately 84 million inhabitants (2024)."; - default -> country + " population data is not available in this demo."; - }; - } +# Terminal 1: Dapr sidecar +dapr run --app-id my-agent --dapr-grpc-port 40001 --dapr-http-port 3500 \ + --resources-path ./components - @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."; - default -> "Capital city for " + country + " is not available in this demo."; - }; - } -} +# Terminal 2: Quarkus app +DAPR_GRPC_ENDPOINT=http://localhost:40001 \ +DAPR_HTTP_ENDPOINT=http://localhost:3500 \ +mvn quarkus:dev -Ddebug=false ``` -When the LLM decides to call `getPopulation` or `getCapital`, the Dapr extension intercepts the call and executes it as a `ToolCallActivity`. This means every tool invocation is recorded in the Dapr workflow history and can be retried automatically on failure. +## LLM Provider Options -**Try it:** - -```bash -curl "http://localhost:8080/research?country=France" -``` - -## Running the Examples - -1. Set your OpenAI API key: - -```bash -export OPENAI_API_KEY=sk-... +### Ollama (direct) +```properties +quarkus.langchain4j.ollama.chat-model.model-id=llama3.1:8b ``` -2. Start in Quarkus dev mode (Dapr Dev Services will start automatically): - -```bash -cd quarkus/examples -mvn quarkus:dev +### Dapr Conversation API (provider-agnostic) +```properties +quarkus.langchain4j.chat-model.provider=dapr-conversation +quarkus.langchain4j.dapr.component-name=llm ``` -3. Call the endpoints: +Swap LLM providers by changing the Dapr component YAML — no Java code changes. -```bash -# Sequential story creation -curl "http://localhost:8080/story?topic=space+exploration&style=noir" +## Example Project -# Parallel story + research -curl "http://localhost:8080/parallel?topic=robots&country=Japan&style=sci-fi" - -# Standalone research with tool calls -curl "http://localhost:8080/research?country=Germany" -``` +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. -## Summary +## Known Limitations -`quarkus-agentic-dapr` lets you keep writing standard LangChain4j agent code while gaining the production-grade durability, observability, and fault tolerance of Dapr Workflows. Swap the dependency, add two lines of configuration, and your agents are now backed by durable workflows — no code changes required. \ No newline at end of file +- **Nested composites**: `@ParallelAgent` inside `@SequenceAgent` is unstable (input type mismatch) +- **Crash recovery**: Workflow history survives but mid-agent resumption requires future work +- **Small models**: llama3.2 (3B) sometimes malforms tool call arguments; llama3.1:8b+ recommended diff --git a/quarkus/pom.xml b/quarkus/pom.xml index dd5e0a5ea2..99b9ce93ea 100644 --- a/quarkus/pom.xml +++ b/quarkus/pom.xml @@ -21,6 +21,7 @@ runtime deployment quarkus-agentic-dapr-agents-registry + quarkus-langchain4j-dapr examples diff --git a/quarkus/quarkus-langchain4j-dapr/deployment/pom.xml b/quarkus/quarkus-langchain4j-dapr/deployment/pom.xml new file mode 100644 index 0000000000..8f520044f3 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr/deployment/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + io.dapr.quarkus + quarkus-langchain4j-dapr-parent + 0.1.0-SNAPSHOT + ../pom.xml + + + quarkus-langchain4j-dapr-deployment + Quarkus LangChain4j Dapr Conversation - Deployment + + + + io.dapr.quarkus + quarkus-langchain4j-dapr + ${project.version} + + + io.quarkiverse.langchain4j + quarkus-langchain4j-core-deployment + ${quarkus-langchain4j.version} + + + io.quarkus + quarkus-arc-deployment + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + diff --git a/quarkus/quarkus-langchain4j-dapr/deployment/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/deployment/DaprConversationProcessor.java b/quarkus/quarkus-langchain4j-dapr/deployment/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/deployment/DaprConversationProcessor.java new file mode 100644 index 0000000000..ffb7139d85 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr/deployment/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/deployment/DaprConversationProcessor.java @@ -0,0 +1,71 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.chatmodel.deployment; + +import dev.langchain4j.model.chat.ChatModel; +import io.dapr.quarkus.langchain4j.chatmodel.DaprConversationConfig; +import io.dapr.quarkus.langchain4j.chatmodel.DaprConversationRecorder; +import io.quarkiverse.langchain4j.deployment.items.ChatModelProviderCandidateBuildItem; +import io.quarkiverse.langchain4j.deployment.items.SelectedChatModelProviderBuildItem; +import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.FeatureBuildItem; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.List; + +/** + * Quarkus deployment processor for the Dapr Conversation ChatModel provider. + */ +public class DaprConversationProcessor { + + private static final String FEATURE = "langchain4j-dapr-conversation"; + private static final String PROVIDER = "dapr-conversation"; + + @BuildStep + FeatureBuildItem feature() { + return new FeatureBuildItem(FEATURE); + } + + @BuildStep + void providerCandidates(BuildProducer candidates) { + candidates.produce(new ChatModelProviderCandidateBuildItem(PROVIDER)); + } + + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + void generateBeans(DaprConversationRecorder recorder, + List selectedProviders, + BuildProducer syntheticBeans) { + + for (SelectedChatModelProviderBuildItem selected : selectedProviders) { + if (!PROVIDER.equals(selected.getProvider())) { + continue; + } + + SyntheticBeanBuildItem.ExtendedBeanConfigurator configurator = + SyntheticBeanBuildItem.configure(ChatModel.class) + .scope(ApplicationScoped.class) + .setRuntimeInit() + .defaultBean() + .unremovable() + .createWith(recorder.chatModel()); + + syntheticBeans.produce(configurator.done()); + } + } +} diff --git a/quarkus/quarkus-langchain4j-dapr/pom.xml b/quarkus/quarkus-langchain4j-dapr/pom.xml new file mode 100644 index 0000000000..838eee465e --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr/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-parent + pom + Quarkus LangChain4j Dapr Conversation - Parent + + + runtime + deployment + + diff --git a/quarkus/quarkus-langchain4j-dapr/runtime/pom.xml b/quarkus/quarkus-langchain4j-dapr/runtime/pom.xml new file mode 100644 index 0000000000..2d9816ddbc --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr/runtime/pom.xml @@ -0,0 +1,62 @@ + + + 4.0.0 + + io.dapr.quarkus + quarkus-langchain4j-dapr-parent + 0.1.0-SNAPSHOT + ../pom.xml + + + quarkus-langchain4j-dapr + Quarkus LangChain4j Dapr Conversation - Runtime + + + + io.quarkiverse.langchain4j + quarkus-langchain4j-core + ${quarkus-langchain4j.version} + + + io.quarkiverse.dapr + quarkus-dapr + ${quarkus-dapr.version} + + + io.dapr + dapr-sdk + ${dapr.sdk.version} + + + io.quarkus + quarkus-arc + + + + + + + src/main/resources + true + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + diff --git a/quarkus/quarkus-langchain4j-dapr/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationChatModel.java b/quarkus/quarkus-langchain4j-dapr/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationChatModel.java new file mode 100644 index 0000000000..a9bafa399a --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationChatModel.java @@ -0,0 +1,189 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.chatmodel; + +import dev.langchain4j.agent.tool.ToolSpecification; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.ToolExecutionResultMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import io.dapr.client.DaprPreviewClient; +import io.dapr.client.domain.ConversationInputAlpha2; +import io.dapr.client.domain.ConversationMessage; +import io.dapr.client.domain.ConversationMessageContent; +import io.dapr.client.domain.ConversationRequestAlpha2; +import io.dapr.client.domain.ConversationResponseAlpha2; +import io.dapr.client.domain.ConversationResultChoices; +import io.dapr.client.domain.ConversationResultMessage; +import io.dapr.client.domain.ConversationToolCalls; +import io.dapr.client.domain.ConversationToolCallsOfFunction; +import io.dapr.client.domain.ConversationTools; +import io.dapr.client.domain.ConversationToolsFunction; +import org.jboss.logging.Logger; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * LangChain4j {@link ChatModel} implementation backed by the Dapr Conversation API. + * + *

This allows swapping LLM providers (OpenAI, Anthropic, etc.) by changing the + * Dapr component configuration — no Java code changes needed. + */ +public class DaprConversationChatModel implements ChatModel { + + private static final Logger LOG = Logger.getLogger(DaprConversationChatModel.class); + + private final DaprPreviewClient client; + private final String componentName; + private final double temperature; + + /** + * Creates a new DaprConversationChatModel. + * + * @param client the Dapr preview client + * @param componentName the Dapr conversation component name + * @param temperature the temperature for generation + */ + public DaprConversationChatModel(DaprPreviewClient client, + String componentName, double temperature) { + this.client = client; + this.componentName = componentName; + this.temperature = temperature; + } + + @Override + public ChatResponse chat(ChatRequest chatRequest) { + List daprMessages = new ArrayList<>(); + + // Convert LangChain4j messages to Dapr messages + for (ChatMessage msg : chatRequest.messages()) { + daprMessages.add(toDaprMessage(msg)); + } + + ConversationInputAlpha2 input = new ConversationInputAlpha2(daprMessages); + + ConversationRequestAlpha2 request = new ConversationRequestAlpha2( + componentName, List.of(input)); + request.setTemperature(temperature); + + // Convert tool specifications if present + if (chatRequest.toolSpecifications() != null && !chatRequest.toolSpecifications().isEmpty()) { + List tools = new ArrayList<>(); + for (ToolSpecification spec : chatRequest.toolSpecifications()) { + tools.add(toDaprTool(spec)); + } + request.setTools(tools); + request.setToolChoice("auto"); + } + + LOG.debugf("Sending conversation request to Dapr component '%s' with %d messages", + componentName, daprMessages.size()); + + ConversationResponseAlpha2 response = client.converseAlpha2(request).block(); + + return fromDaprResponse(response); + } + + private ConversationMessage toDaprMessage(ChatMessage msg) { + if (msg instanceof SystemMessage sm) { + return new io.dapr.client.domain.SystemMessage( + List.of(new ConversationMessageContent(sm.text()))); + } + if (msg instanceof UserMessage um) { + return new io.dapr.client.domain.UserMessage( + List.of(new ConversationMessageContent(um.singleText()))); + } + if (msg instanceof AiMessage ai) { + List toolCalls = null; + if (ai.hasToolExecutionRequests()) { + toolCalls = new ArrayList<>(); + for (var req : ai.toolExecutionRequests()) { + ConversationToolCalls tc = new ConversationToolCalls( + new ConversationToolCallsOfFunction(req.name(), req.arguments())); + tc.setId(req.id()); + toolCalls.add(tc); + } + } + String text = ai.text() != null ? ai.text() : ""; + return new io.dapr.client.domain.AssistantMessage( + List.of(new ConversationMessageContent(text)), toolCalls); + } + if (msg instanceof ToolExecutionResultMessage tr) { + io.dapr.client.domain.ToolMessage tm = new io.dapr.client.domain.ToolMessage( + List.of(new ConversationMessageContent(tr.text()))); + tm.setToolId(tr.id()); + return tm; + } + throw new IllegalArgumentException("Unsupported message type: " + msg.getClass()); + } + + private ConversationTools toDaprTool(ToolSpecification spec) { + // Use ToolSpecification's JSON Schema representation directly + Map parameters = new HashMap<>(); + parameters.put("type", "object"); + // TODO: map ToolSpecification.parameters() to JSON Schema when available + ConversationToolsFunction fn = new ConversationToolsFunction(spec.name(), parameters); + if (spec.description() != null) { + fn.setDescription(spec.description()); + } + return new ConversationTools(fn); + } + + private ChatResponse fromDaprResponse(ConversationResponseAlpha2 response) { + if (response == null || response.getOutputs() == null || response.getOutputs().isEmpty()) { + return ChatResponse.builder() + .aiMessage(AiMessage.from("")) + .build(); + } + + var result = response.getOutputs().get(0); + if (result.getChoices() == null || result.getChoices().isEmpty()) { + return ChatResponse.builder() + .aiMessage(AiMessage.from("")) + .build(); + } + + ConversationResultChoices choice = result.getChoices().get(0); + ConversationResultMessage message = choice.getMessage(); + + // Check for tool calls + if (message != null && message.hasToolCalls()) { + List requests = new ArrayList<>(); + for (ConversationToolCalls tc : message.getToolCalls()) { + requests.add(dev.langchain4j.agent.tool.ToolExecutionRequest.builder() + .id(tc.getId()) + .name(tc.getFunction().getName()) + .arguments(tc.getFunction().getArguments()) + .build()); + } + return ChatResponse.builder() + .aiMessage(AiMessage.from(requests)) + .build(); + } + + // Text response + String content = message != null && message.getContent() != null + ? message.getContent() : ""; + return ChatResponse.builder() + .aiMessage(AiMessage.from(content)) + .build(); + } +} diff --git a/quarkus/quarkus-langchain4j-dapr/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationConfig.java b/quarkus/quarkus-langchain4j-dapr/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationConfig.java new file mode 100644 index 0000000000..7cf3a58446 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationConfig.java @@ -0,0 +1,53 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.chatmodel; + +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +import java.util.Optional; + +/** + * Runtime configuration for the Dapr Conversation ChatModel provider. + */ +@ConfigRoot(phase = ConfigPhase.RUN_TIME) +@ConfigMapping(prefix = "quarkus.langchain4j.dapr") +public interface DaprConversationConfig { + + /** + * The Dapr conversation component name. + * + * @return the component name + */ + @WithDefault("llm") + String componentName(); + + /** + * The temperature for generation (0.0 - 2.0). + * + * @return the temperature + */ + @WithDefault("0.7") + double temperature(); + + /** + * Whether the Dapr conversation integration is enabled. + * + * @return true if enabled + */ + @WithDefault("true") + boolean enabled(); +} diff --git a/quarkus/quarkus-langchain4j-dapr/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationRecorder.java b/quarkus/quarkus-langchain4j-dapr/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationRecorder.java new file mode 100644 index 0000000000..9bad2c46d7 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationRecorder.java @@ -0,0 +1,60 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.chatmodel; + +import dev.langchain4j.model.chat.ChatModel; +import io.dapr.client.DaprClientBuilder; +import io.dapr.client.DaprPreviewClient; +import io.quarkus.arc.SyntheticCreationalContext; +import io.quarkus.runtime.RuntimeValue; +import io.quarkus.runtime.annotations.Recorder; +import org.jboss.logging.Logger; + +import java.util.function.Function; + +/** + * Quarkus recorder that produces {@link DaprConversationChatModel} instances. + */ +@Recorder +public class DaprConversationRecorder { + + private static final Logger LOG = Logger.getLogger(DaprConversationRecorder.class); + + private final RuntimeValue config; + + /** + * Creates the recorder with runtime configuration. + * + * @param config the runtime configuration wrapped as RuntimeValue + */ + public DaprConversationRecorder(RuntimeValue config) { + this.config = config; + } + + /** + * Returns a function that creates a {@link DaprConversationChatModel}. + * + * @return a function that creates the ChatModel from CDI context + */ + public Function, ChatModel> chatModel() { + return ctx -> { + DaprConversationConfig cfg = config.getValue(); + DaprPreviewClient client = new DaprClientBuilder().buildPreviewClient(); + LOG.infof("DaprConversationChatModel created — component=%s, temperature=%.1f", + cfg.componentName(), cfg.temperature()); + return new DaprConversationChatModel( + client, cfg.componentName(), cfg.temperature()); + }; + } +} diff --git a/quarkus/quarkus-langchain4j-dapr/runtime/src/main/resources/META-INF/quarkus-extension.properties b/quarkus/quarkus-langchain4j-dapr/runtime/src/main/resources/META-INF/quarkus-extension.properties new file mode 100644 index 0000000000..07b7dc741c --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr/runtime/src/main/resources/META-INF/quarkus-extension.properties @@ -0,0 +1 @@ +deployment-artifact=io.dapr.quarkus\:quarkus-langchain4j-dapr-deployment\:0.1.0-SNAPSHOT diff --git a/quarkus/quarkus-langchain4j-dapr/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/quarkus/quarkus-langchain4j-dapr/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 0000000000..b3fc146ff5 --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,11 @@ +name: Quarkus LangChain4j Dapr Conversation +description: LangChain4j ChatModel backed by Dapr Conversation API +artifact: ${project.groupId}:${project.artifactId}:${project.version} +metadata: + keywords: + - langchain4j + - dapr + - conversation + - llm + categories: + - "AI" diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/ChatModelProviderName.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/ChatModelProviderName.java new file mode 100644 index 0000000000..94b8d5264d --- /dev/null +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/ChatModelProviderName.java @@ -0,0 +1,44 @@ +/* + * 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; + +/** + * Static holder for the ChatModel provider name. + * Set at build time by the deployment processor. + */ +public final class ChatModelProviderName { + + private static volatile String name = "unknown"; + + private ChatModelProviderName() { + } + + /** + * Sets the provider name. + * + * @param providerName the provider name (e.g., "dapr-conversation", "ollama") + */ + public static void set(String providerName) { + name = providerName; + } + + /** + * Returns the provider name. + * + * @return the provider name + */ + public static String get() { + return name; + } +} diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelWrapper.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelWrapper.java index 5ac8c6f346..ba4137fd4f 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelWrapper.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelWrapper.java @@ -41,6 +41,16 @@ public class DaprChatModelWrapper implements ChatModel { private static final Logger LOG = Logger.getLogger(DaprChatModelWrapper.class); private volatile ChatModel delegate; + private volatile String delegateClassName = "unknown"; + + /** + * Returns the simple class name of the underlying ChatModel provider. + * + * @return the provider class name (e.g., "DaprConversationChatModel") + */ + public String getDelegateClassName() { + return delegateClassName; + } @Inject @SuppressWarnings("unchecked") @@ -52,8 +62,18 @@ void initDelegate() { (jakarta.enterprise.inject.spi.Bean) bean); this.delegate = (ChatModel) beanManager.getReference( bean, ChatModel.class, ctx); + // Read the configured provider name from application config + try { + this.delegateClassName = org.eclipse.microprofile.config.ConfigProvider + .getConfig().getOptionalValue( + "quarkus.langchain4j.chat-model.provider", String.class) + .orElse("unknown"); + } catch (Exception e) { + this.delegateClassName = "unknown"; + } + ChatModelProviderName.set(this.delegateClassName); LOG.infof("DaprChatModelWrapper initialized — delegate: %s", - bean.getBeanClass().getName()); + delegateClassName); return; } } diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallActivity.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallActivity.java index fb79b291da..df8eca6e9e 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallActivity.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallActivity.java @@ -15,6 +15,7 @@ import io.dapr.quarkus.langchain4j.agent.AgentRunContext; import io.dapr.quarkus.langchain4j.agent.DaprAgentRunRegistry; +import io.dapr.quarkus.langchain4j.agent.DaprChatModelWrapper; import io.dapr.quarkus.langchain4j.agent.DaprToolCallInterceptor; import io.dapr.workflows.WorkflowActivity; import io.dapr.workflows.WorkflowActivityContext; @@ -93,7 +94,9 @@ public Object run(WorkflowActivityContext ctx) { runCtx.completeCall(input.llmCallId(), result); LOG.infof("[AgentRun:%s][LlmCall:%s] POST-completeCall — future resolved", input.agentRunId(), input.llmCallId()); - return new LlmCallOutput(input.methodName(), input.prompt(), responseText); + // Resolve the actual ChatModel class name for observability + String chatModelClass = resolveChatModelClass(pendingCall.target()); + return new LlmCallOutput(input.methodName(), input.prompt(), responseText, chatModelClass); } catch (InvocationTargetException ite) { Throwable cause = ite.getCause() != null ? ite.getCause() : ite; LOG.errorf("[AgentRun:%s][LlmCall:%s] LLM call failed: %s — %s", @@ -131,4 +134,19 @@ private String extractResponseText(Object result) { } return String.valueOf(result); } + + /** + * Resolves the actual ChatModel provider name from the CDI container. + * Skips the Dapr wrapper/decorator beans to find the underlying provider + * (e.g., "DaprConversationChatModel", "OllamaChatModel"). + */ + private String resolveChatModelClass(Object target) { + try { + DaprChatModelWrapper wrapper = io.quarkus.arc.Arc.container() + .instance(DaprChatModelWrapper.class).get(); + return wrapper.getDelegateClassName(); + } catch (Exception ignored) { + return "unknown"; + } + } } diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallOutput.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallOutput.java index 7dc3a5fa23..fc98820778 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallOutput.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallOutput.java @@ -25,5 +25,6 @@ * @param response AI response text extracted from {@code ChatResponse.aiMessage().text()}; * this is the exact text the model returned to the agent */ -public record LlmCallOutput(String methodName, String prompt, String response) { +public record LlmCallOutput(String methodName, String prompt, String response, + String chatModelClass) { } diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowRuntimeRecorder.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowRuntimeRecorder.java index eefb635fe4..d53589160b 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowRuntimeRecorder.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowRuntimeRecorder.java @@ -98,6 +98,16 @@ public void registerAgentName(String interfaceName, String agentName) { * * @param builder the builder runtime value */ + /** + * Sets the ChatModel provider name for observability. + * + * @param providerName the provider name (e.g., "dapr-conversation", "ollama") + */ + public void setChatModelProvider(String providerName) { + io.dapr.quarkus.langchain4j.agent.ChatModelProviderName.set(providerName); + LOG.infof("ChatModel provider: %s", providerName); + } + public void startRuntime(RuntimeValue builder) { WorkflowRuntime runtime = builder.getValue().build(); runtime.start(false); From 378c2f89f54366618fe9ce902764fe29d3aea7ac Mon Sep 17 00:00:00 2001 From: Javier Aliaga Date: Tue, 9 Jun 2026 16:22:11 +0200 Subject: [PATCH 04/21] feat(quarkus): add agent-level crash recovery via RecoveryAgentActivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the process crashes mid-agent-execution, Dapr replays the workflow and completed activities return cached results. The in-progress activity fails with TaskFailedException because the in-memory AgentRunContext is gone. AgentRunWorkflow catches this and delegates to RecoveryAgentActivity, which re-runs the agent's entire ReAct loop from scratch using ChatModel.chat() directly and ToolRegistry for tool dispatch — bypassing LangChain4j AiServices entirely. New classes: RecoveryAgentActivity, RecoveryAgentInput/Output, ToolRegistry (CDI @Tool scanner), AgentToolClassRegistry (build-time agent→toolbox mapping). AgentRunInput/Output extended with toolClassNames and recoveryResult fields. Signed-off-by: Javier Aliaga --- quarkus/README.md | 45 +++- .../deployment/DaprAgenticProcessor.java | 35 +++ .../agent/AgentRunLifecycleManager.java | 6 +- .../agent/DaprAgentMethodInterceptor.java | 4 +- .../recovery/AgentToolClassRegistry.java | 51 ++++ .../agent/recovery/RecoveryAgentActivity.java | 242 ++++++++++++++++++ .../agent/recovery/RecoveryAgentInput.java | 35 +++ .../agent/recovery/RecoveryAgentOutput.java | 29 +++ .../agent/recovery/ToolRegistry.java | 179 +++++++++++++ .../agent/workflow/AgentRunInput.java | 8 +- .../agent/workflow/AgentRunOutput.java | 19 +- .../agent/workflow/AgentRunWorkflow.java | 115 ++++++--- .../workflow/DaprWorkflowRuntimeRecorder.java | 19 +- .../ConditionalOrchestrationWorkflow.java | 4 +- .../LoopOrchestrationWorkflow.java | 4 +- .../ParallelOrchestrationWorkflow.java | 4 +- .../SequentialOrchestrationWorkflow.java | 4 +- 17 files changed, 750 insertions(+), 53 deletions(-) create mode 100644 quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/AgentToolClassRegistry.java create mode 100644 quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/RecoveryAgentActivity.java create mode 100644 quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/RecoveryAgentInput.java create mode 100644 quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/RecoveryAgentOutput.java create mode 100644 quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/ToolRegistry.java diff --git a/quarkus/README.md b/quarkus/README.md index a003069c16..f0bf31f151 100644 --- a/quarkus/README.md +++ b/quarkus/README.md @@ -104,8 +104,51 @@ Swap LLM providers by changing the Dapr component YAML — no Java code changes. 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 + +If the process crashes mid-execution, completed agents are not re-run. The in-progress agent is automatically re-run from scratch using its original prompt and tools. + +### How it works + +1. **Normal operation**: LangChain4j's AiServices drives the ReAct loop. Each LLM call and tool call is recorded as a Dapr Workflow activity. +2. **Crash**: The process dies. Dapr workflow history persists. +3. **Restart**: Dapr replays the workflow. Completed activities return cached results instantly. +4. **Recovery detection**: The in-progress activity is re-dispatched but fails because the in-memory `AgentRunContext` is gone. `AgentRunWorkflow` catches the failure. +5. **Agent re-run**: `RecoveryAgentActivity` re-executes the agent's entire ReAct loop from scratch — calling `ChatModel.chat()` directly and invoking tools via `ToolRegistry`. + +### Recovery granularity + +| Scope | Behavior | +|-------|----------| +| Orchestration (e.g., Agent1 → Agent2 → Agent3) | Completed agents are skipped (Dapr child workflow replay). Only the in-progress agent re-runs. | +| Single agent (LLM calls + tool calls) | The entire agent re-runs from its original prompt. Individual LLM/tool calls within the agent are not skipped. | + +### 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 agents show cached results. The crashed agent shows "Recovery timeout" in logs, then re-runs and completes. + +### Key classes + +| Class | Role | +|-------|------| +| `AgentRunWorkflow` | Detects missing AiServices thread via timer, triggers recovery | +| `RecoveryAgentActivity` | Self-contained ReAct loop: ChatModel.chat() + tool dispatch | +| `ToolRegistry` | CDI bean that discovers @Tool methods at startup for recovery | +| `AgentToolClassRegistry` | Maps agent names to their @ToolBox classes (populated at build time) | + ## Known Limitations - **Nested composites**: `@ParallelAgent` inside `@SequenceAgent` is unstable (input type mismatch) -- **Crash recovery**: Workflow history survives but mid-agent resumption requires future work +- **Recovery granularity**: Agent-level only — individual LLM/tool calls within an agent are re-executed (not skipped) - **Small models**: llama3.2 (3B) sometimes malforms tool call arguments; llama3.1:8b+ recommended diff --git a/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java b/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java index 7487b5d61c..4b9df807db 100644 --- a/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java +++ b/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java @@ -172,6 +172,12 @@ public class DaprAgenticProcessor { "io.dapr.quarkus.langchain4j.agent.workflow.AgentRunWorkflow", }; + /** + * 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[] ACTIVITY_CLASSES = { "io.dapr.quarkus.langchain4j.workflow.orchestration.activities.AgentExecutionActivity", "io.dapr.quarkus.langchain4j.workflow.orchestration.activities.ExitConditionCheckActivity", @@ -180,6 +186,8 @@ public class DaprAgenticProcessor { "io.dapr.quarkus.langchain4j.agent.activities.ToolCallActivity", // Per-LLM-call activity "io.dapr.quarkus.langchain4j.agent.activities.LlmCallActivity", + // Crash recovery activity + "io.dapr.quarkus.langchain4j.agent.recovery.RecoveryAgentActivity", }; @BuildStep @@ -313,6 +321,30 @@ void setupWorkflowRuntime(DaprWorkflowRuntimeRecorder recorder, } } + // Register agent → tool class mappings for crash recovery. + // For each @Agent method with a @ToolBox annotation, extract the tool class names + // so RecoveryAgentActivity knows which tools to provide when re-running the agent. + 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(); + java.util.List toolClassNames = new java.util.ArrayList<>(); + for (Type t : toolBoxTypes) { + toolClassNames.add(t.name().toString()); + } + recorder.registerAgentToolClasses(agentName, toolClassNames); + } + } + recorder.startRuntime(builder); } @@ -341,6 +373,9 @@ void registerAdditionalBeans(BuildProducer additionalBe "io.dapr.quarkus.langchain4j.agent.DaprChatModelWrapper")); additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf( "io.dapr.quarkus.langchain4j.agent.DaprChatModelDecorator")); + // Tool registry for crash recovery — scans @Tool CDI beans at startup. + additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf( + "io.dapr.quarkus.langchain4j.agent.recovery.ToolRegistry")); } /** diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/AgentRunLifecycleManager.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/AgentRunLifecycleManager.java index 8cb9d20a8a..b546d2c781 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/AgentRunLifecycleManager.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/AgentRunLifecycleManager.java @@ -13,6 +13,7 @@ package io.dapr.quarkus.langchain4j.agent; +import io.dapr.quarkus.langchain4j.agent.recovery.AgentToolClassRegistry; import io.dapr.quarkus.langchain4j.agent.workflow.AgentEvent; import io.dapr.quarkus.langchain4j.agent.workflow.AgentRunInput; import io.dapr.quarkus.langchain4j.workflow.DaprAgentServiceUtil; @@ -22,6 +23,7 @@ import jakarta.inject.Inject; import org.jboss.logging.Logger; +import java.util.List; import java.util.UUID; /** * Request-scoped CDI bean that manages the lifecycle of a lazily-started @@ -71,9 +73,11 @@ public String getOrActivate(String agentName, String userMessage, String systemM String name = (agentName != null && !agentName.isBlank()) ? agentName : "standalone"; AgentRunContext runContext = new AgentRunContext(agentRunId); DaprAgentRunRegistry.register(agentRunId, runContext); + List toolClasses = AgentToolClassRegistry.get(name); workflowClient.scheduleNewWorkflow( DaprAgentServiceUtil.agentWorkflowName(name), - new AgentRunInput(agentRunId, name, userMessage, systemMessage), agentRunId); + new AgentRunInput(agentRunId, name, userMessage, systemMessage, toolClasses), + agentRunId); DaprAgentContextHolder.set(agentRunId); LOG.infof("[AgentRun:%s] AgentRunWorkflow started — workflow=%s, agent=%s", agentRunId, DaprAgentServiceUtil.agentWorkflowName(name), name); diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprAgentMethodInterceptor.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprAgentMethodInterceptor.java index 58f6d3905e..fd29bcdcb6 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprAgentMethodInterceptor.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprAgentMethodInterceptor.java @@ -16,6 +16,7 @@ import dev.langchain4j.agentic.Agent; import dev.langchain4j.service.SystemMessage; import dev.langchain4j.service.UserMessage; +import io.dapr.quarkus.langchain4j.agent.recovery.AgentToolClassRegistry; import io.dapr.quarkus.langchain4j.agent.workflow.AgentEvent; import io.dapr.quarkus.langchain4j.agent.workflow.AgentRunInput; import io.dapr.quarkus.langchain4j.workflow.DaprAgentServiceUtil; @@ -87,7 +88,8 @@ public Object intercept(InvocationContext ctx) throws Exception { DaprAgentRunRegistry.register(agentRunId, runContext); workflowClient.scheduleNewWorkflow( DaprAgentServiceUtil.agentWorkflowName(agentName), - new AgentRunInput(agentRunId, agentName, userMessage, systemMessage), agentRunId); + new AgentRunInput(agentRunId, agentName, userMessage, systemMessage, + AgentToolClassRegistry.get(agentName)), agentRunId); DaprAgentContextHolder.set(agentRunId); try { diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/AgentToolClassRegistry.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/AgentToolClassRegistry.java new file mode 100644 index 0000000000..df6b14a15b --- /dev/null +++ b/quarkus/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/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/RecoveryAgentActivity.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/RecoveryAgentActivity.java new file mode 100644 index 0000000000..cd8a4c4253 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/RecoveryAgentActivity.java @@ -0,0 +1,242 @@ +/* + * 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 com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.langchain4j.agent.tool.ToolExecutionRequest; +import dev.langchain4j.agent.tool.ToolSpecification; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.ToolExecutionResultMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import io.dapr.quarkus.langchain4j.agent.DaprToolCallInterceptor; +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.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Dapr Workflow Activity that re-runs an agent's entire ReAct loop from scratch + * after a crash. This is the core of agent-level crash recovery. + * + *

Unlike {@link io.dapr.quarkus.langchain4j.agent.activities.LlmCallActivity} which + * relies on in-memory {@code PendingCall} objects, this activity is self-contained: + * it calls {@code ChatModel.chat()} directly and invokes tools via {@link ToolRegistry}. + * + *

The activity runs as a single durable unit. If the process crashes during recovery, + * Dapr will re-dispatch this activity from the beginning (the agent re-runs again). + */ +@ApplicationScoped +@ActivityMetadata(name = "recovery-agent") +public class RecoveryAgentActivity implements WorkflowActivity { + + private static final Logger LOG = Logger.getLogger(RecoveryAgentActivity.class); + private static final int MAX_ITERATIONS = 10; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public Object run(WorkflowActivityContext ctx) { + RecoveryAgentInput input = ctx.getInput(RecoveryAgentInput.class); + + LOG.infof("[Recovery:%s] Starting recovery for agent=%s", + input.agentRunId(), input.agentName()); + + ToolRegistry toolRegistry = Arc.container().instance(ToolRegistry.class).get(); + + // Build tool specifications from the agent's @ToolBox classes + List toolSpecs = input.toolClassNames() != null + ? toolRegistry.getToolSpecsForClasses(input.toolClassNames()) + : toolRegistry.getAllToolSpecs(); + + LOG.infof("[Recovery:%s] Available tools: %d", input.agentRunId(), toolSpecs.size()); + + ChatModel chatModel = resolveChatModel(); + + // Build initial messages + List messages = new ArrayList<>(); + if (input.systemMessage() != null && !input.systemMessage().isBlank()) { + messages.add(new SystemMessage(input.systemMessage())); + } + if (input.userMessage() != null && !input.userMessage().isBlank()) { + messages.add(new UserMessage(input.userMessage())); + } + + int llmCalls = 0; + int toolCalls = 0; + + // ReAct loop: call LLM, execute tools, repeat until text response + for (int i = 0; i < MAX_ITERATIONS; i++) { + LOG.infof("[Recovery:%s][iter:%d] Calling ChatModel with %d messages, %d tools", + input.agentRunId(), i, messages.size(), toolSpecs.size()); + + ChatRequest.Builder requestBuilder = ChatRequest.builder().messages(messages); + if (!toolSpecs.isEmpty()) { + requestBuilder.toolSpecifications(toolSpecs); + } + + // Bypass DaprChatModelDecorator routing + DaprToolCallInterceptor.IS_ACTIVITY_CALL.set(Boolean.TRUE); + ChatResponse response; + try { + response = chatModel.chat(requestBuilder.build()); + } finally { + DaprToolCallInterceptor.IS_ACTIVITY_CALL.remove(); + } + llmCalls++; + + AiMessage aiMessage = response.aiMessage(); + messages.add(aiMessage); + + // Check if LLM returned tool calls + if (aiMessage.toolExecutionRequests() != null + && !aiMessage.toolExecutionRequests().isEmpty()) { + LOG.infof("[Recovery:%s][iter:%d] LLM requested %d tool calls", + input.agentRunId(), i, aiMessage.toolExecutionRequests().size()); + + for (ToolExecutionRequest toolRequest : aiMessage.toolExecutionRequests()) { + String toolName = toolRequest.name(); + String argsJson = toolRequest.arguments(); + + LOG.infof("[Recovery:%s][iter:%d] Executing tool: %s(%s)", + input.agentRunId(), i, toolName, argsJson); + + try { + Object[] args = parseToolArguments(toolRegistry, toolName, argsJson); + String result = toolRegistry.invokeTool(toolName, args); + toolCalls++; + + LOG.infof("[Recovery:%s][iter:%d] Tool %s returned: %s", + input.agentRunId(), i, toolName, truncate(result, 200)); + + messages.add(new ToolExecutionResultMessage( + toolRequest.id(), toolName, result)); + } catch (Exception e) { + LOG.errorf("[Recovery:%s][iter:%d] Tool %s failed: %s", + input.agentRunId(), i, toolName, e.getMessage()); + messages.add(new ToolExecutionResultMessage( + toolRequest.id(), toolName, "Error: " + e.getMessage())); + } + } + } else { + // LLM returned text — agent is done + String resultText = aiMessage.text(); + LOG.infof("[Recovery:%s] Recovery complete — llmCalls=%d, toolCalls=%d, result=%s", + input.agentRunId(), llmCalls, toolCalls, truncate(resultText, 200)); + return new RecoveryAgentOutput(input.agentName(), resultText, llmCalls, toolCalls); + } + } + + // Max iterations reached + LOG.warnf("[Recovery:%s] Max iterations (%d) reached — returning last response", + input.agentRunId(), MAX_ITERATIONS); + return new RecoveryAgentOutput(input.agentName(), + "Recovery reached max iterations (" + MAX_ITERATIONS + ")", + llmCalls, toolCalls); + } + + /** + * Parses tool arguments from the LLM's JSON string into an Object array + * matching the tool method's parameter order. + */ + private Object[] parseToolArguments(ToolRegistry toolRegistry, String toolName, String argsJson) { + ToolRegistry.ToolEntry entry = toolRegistry.getToolEntry(toolName); + if (entry == null) { + return new Object[0]; + } + + Method method = entry.method(); + Parameter[] params = method.getParameters(); + if (params.length == 0) { + return new Object[0]; + } + + try { + Map argsMap = MAPPER.readValue(argsJson, + new TypeReference>() { + }); + + Object[] result = new Object[params.length]; + for (int i = 0; i < params.length; i++) { + // Use Java parameter name (requires -parameters compiler flag) — this matches + // how LangChain4j generates ToolSpecification parameter names. The @P annotation + // value is the description, not the name. + String paramName = params[i].getName(); + Object value = argsMap.get(paramName); + if (value == null && argsMap.size() == 1 && params.length == 1) { + // Single-param tool: use whatever key the LLM sent + value = argsMap.values().iterator().next(); + } + result[i] = convertArgument(value, params[i].getType()); + } + return result; + } catch (Exception e) { + LOG.warnf("Failed to parse tool args for %s: %s — trying positional", toolName, e.getMessage()); + // Fallback: try single-argument tools + if (params.length == 1) { + return new Object[]{argsJson}; + } + return new Object[params.length]; + } + } + + /** + * Converts a JSON value to the target parameter type. + */ + private Object convertArgument(Object value, Class targetType) { + if (value == null) { + return null; + } + if (targetType == String.class) { + return String.valueOf(value); + } + if (targetType == int.class || targetType == Integer.class) { + return value instanceof Number ? ((Number) value).intValue() : Integer.parseInt(String.valueOf(value)); + } + if (targetType == long.class || targetType == Long.class) { + return value instanceof Number ? ((Number) value).longValue() : Long.parseLong(String.valueOf(value)); + } + if (targetType == double.class || targetType == Double.class) { + return value instanceof Number ? ((Number) value).doubleValue() : Double.parseDouble(String.valueOf(value)); + } + if (targetType == boolean.class || targetType == Boolean.class) { + return value instanceof Boolean ? value : Boolean.parseBoolean(String.valueOf(value)); + } + // For complex types, try Jackson conversion + return MAPPER.convertValue(value, targetType); + } + + private ChatModel resolveChatModel() { + return Arc.container().instance(ChatModel.class).get(); + } + + private static String truncate(String s, int maxLength) { + if (s == null) { + return null; + } + return s.length() <= maxLength ? s : s.substring(0, maxLength) + "..."; + } +} diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/RecoveryAgentInput.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/RecoveryAgentInput.java new file mode 100644 index 0000000000..49b16c1cb2 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/RecoveryAgentInput.java @@ -0,0 +1,35 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.agent.recovery; + +import java.util.List; + +/** + * Input for {@link RecoveryAgentActivity}. Contains everything needed to re-run + * the agent's ReAct loop from scratch after a crash. + * + * @param agentRunId the original agent run ID (for logging/correlation) + * @param agentName the agent name from {@code @Agent(name=...)} + * @param systemMessage the system message for the LLM + * @param userMessage the user message for the LLM + * @param toolClassNames fully-qualified class names of {@code @Tool} classes + * (from {@code @ToolBox} annotation) + */ +public record RecoveryAgentInput( + String agentRunId, + String agentName, + String systemMessage, + String userMessage, + List toolClassNames) { +} diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/RecoveryAgentOutput.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/RecoveryAgentOutput.java new file mode 100644 index 0000000000..dc320c5c92 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/RecoveryAgentOutput.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.langchain4j.agent.recovery; + +/** + * Output from {@link RecoveryAgentActivity} after the agent's ReAct loop completes. + * + * @param agentName the agent name + * @param result the final text response from the LLM + * @param llmCalls number of LLM calls made during recovery + * @param toolCalls number of tool calls made during recovery + */ +public record RecoveryAgentOutput( + String agentName, + String result, + int llmCalls, + int toolCalls) { +} diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/ToolRegistry.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/ToolRegistry.java new file mode 100644 index 0000000000..2d47c3934c --- /dev/null +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/ToolRegistry.java @@ -0,0 +1,179 @@ +/* + * 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.dapr.quarkus.langchain4j.agent.DaprToolCallInterceptor; +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; + 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); + } + + /** + * Invokes a tool by name with the given arguments. + * + * @param toolName the tool name (as declared in {@code @Tool}) + * @param args the arguments to pass (already resolved to match method parameters) + * @return the tool result as a string + */ + public String invokeTool(String toolName, Object[] args) { + ToolEntry entry = toolsByName.get(toolName); + if (entry == null) { + throw new IllegalArgumentException("Unknown tool: " + toolName); + } + + Object beanInstance = Arc.container().instance(entry.beanClass()).get(); + if (beanInstance == null) { + throw new IllegalStateException("No CDI bean instance for tool class: " + entry.beanClass().getName()); + } + + DaprToolCallInterceptor.IS_ACTIVITY_CALL.set(Boolean.TRUE); + try { + Object result = entry.method().invoke(beanInstance, args); + return String.valueOf(result); + } catch (Exception e) { + throw new RuntimeException("Tool invocation failed: " + toolName, e); + } finally { + DaprToolCallInterceptor.IS_ACTIVITY_CALL.remove(); + } + } + + /** + * 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)) { + return m; + } + } + } + return null; + } +} diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/workflow/AgentRunInput.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/workflow/AgentRunInput.java index 291dec3b43..06f6cc051e 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/workflow/AgentRunInput.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/workflow/AgentRunInput.java @@ -13,6 +13,8 @@ package io.dapr.quarkus.langchain4j.agent.workflow; +import java.util.List; + /** * Input record for {@link AgentRunWorkflow}. * @@ -26,6 +28,10 @@ * @param systemMessage the {@code @SystemMessage} template text (CDI bean path) or the * rendered system message from the {@code ChatRequest} (AiService path); * may be {@code null} + * @param toolClassNames fully-qualified class names of {@code @Tool} classes available to this + * agent (from {@code @ToolBox} annotation). Used by crash recovery to + * re-run the agent's ReAct loop. May be {@code null} or empty. */ -public record AgentRunInput(String agentRunId, String agentName, String userMessage, String systemMessage) { +public record AgentRunInput(String agentRunId, String agentName, String userMessage, + String systemMessage, List toolClassNames) { } diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/workflow/AgentRunOutput.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/workflow/AgentRunOutput.java index 96a86fdaac..ef82aadd91 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/workflow/AgentRunOutput.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/workflow/AgentRunOutput.java @@ -24,24 +24,29 @@ * workflow custom status after every activity so observers can follow execution * progress in real time, and reflects the final state once {@code "done"} is received. * - * @param agentName human-readable name of the {@code @Agent} that was executed - * @param toolCalls ordered list of tool calls made by the agent, each with its - * input arguments and return value - * @param llmCalls ordered list of LLM calls made by the agent, each with the - * model method name and the response text + * @param agentName human-readable name of the {@code @Agent} that was executed + * @param toolCalls ordered list of tool calls made by the agent, each with its + * input arguments and return value + * @param llmCalls ordered list of LLM calls made by the agent, each with the + * model method name and the response text + * @param recoveryResult if the agent was recovered after a crash, the final text response + * from the recovery ReAct loop; {@code null} for normal completions */ public record AgentRunOutput( String agentName, List toolCalls, - List llmCalls) { + List llmCalls, + String recoveryResult) { /** * Creates an AgentRunOutput with unmodifiable defensive copies of the lists. */ public AgentRunOutput(String agentName, List toolCalls, - List llmCalls) { + List llmCalls, String recoveryResult) { this.agentName = agentName; this.toolCalls = toolCalls == null ? null : Collections.unmodifiableList(List.copyOf(toolCalls)); this.llmCalls = llmCalls == null ? null : Collections.unmodifiableList(List.copyOf(llmCalls)); + this.recoveryResult = recoveryResult; } + } diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/workflow/AgentRunWorkflow.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/workflow/AgentRunWorkflow.java index d853f38437..c63cc7a143 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/workflow/AgentRunWorkflow.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/workflow/AgentRunWorkflow.java @@ -13,12 +13,13 @@ package io.dapr.quarkus.langchain4j.agent.workflow; -import io.dapr.quarkus.langchain4j.agent.activities.LlmCallActivity; +import io.dapr.durabletask.TaskFailedException; import io.dapr.quarkus.langchain4j.agent.activities.LlmCallInput; import io.dapr.quarkus.langchain4j.agent.activities.LlmCallOutput; -import io.dapr.quarkus.langchain4j.agent.activities.ToolCallActivity; import io.dapr.quarkus.langchain4j.agent.activities.ToolCallInput; import io.dapr.quarkus.langchain4j.agent.activities.ToolCallOutput; +import io.dapr.quarkus.langchain4j.agent.recovery.RecoveryAgentInput; +import io.dapr.quarkus.langchain4j.agent.recovery.RecoveryAgentOutput; import io.dapr.workflows.Workflow; import io.dapr.workflows.WorkflowStub; import io.quarkiverse.dapr.workflows.WorkflowMetadata; @@ -31,23 +32,28 @@ * Dapr Workflow representing the execution of a single {@code @Agent}-annotated method, * including all tool and LLM calls the agent makes during its ReAct loop. * - *

Lifecycle

+ *

Lifecycle (normal path)

*
    *
  1. Started by {@link io.dapr.quarkus.langchain4j.workflow.orchestration.activities.AgentExecutionActivity} * (orchestration path) or lazily by {@link io.dapr.quarkus.langchain4j.agent.AgentRunLifecycleManager} * (standalone {@code @Agent} path) just before the agent is submitted.
  2. *
  3. Loops waiting for {@code "agent-event"} external events raised by * {@link io.dapr.quarkus.langchain4j.agent.DaprToolCallInterceptor} and - * {@link io.dapr.quarkus.langchain4j.agent.DaprLlmCallInterceptor}.
  4. - *
  5. For each {@code "tool-call"} event, schedules a {@link ToolCallActivity} that - * executes the tool on the Dapr activity thread and returns a {@link ToolCallOutput}.
  6. - *
  7. For each {@code "llm-call"} event, schedules a {@link LlmCallActivity} that - * executes the LLM call on the Dapr activity thread and returns a {@link LlmCallOutput}.
  8. - *
  9. After each activity, updates the Dapr custom status with an {@link AgentRunOutput} - * snapshot so observers can follow execution progress in real time.
  10. - *
  11. Terminates when a {@code "done"} event is received, setting the final - * {@link AgentRunOutput} as the custom status.
  12. + * {@link io.dapr.quarkus.langchain4j.agent.DaprChatModelDecorator}. + *
  13. For each {@code "tool-call"} event, schedules a + * {@link io.dapr.quarkus.langchain4j.agent.activities.ToolCallActivity}.
  14. + *
  15. For each {@code "llm-call"} event, schedules a + * {@link io.dapr.quarkus.langchain4j.agent.activities.LlmCallActivity}.
  16. + *
  17. Terminates when a {@code "done"} event is received.
  18. *
+ * + *

Crash recovery

+ * After a crash, the Dapr runtime replays this workflow. Cached events and activities return + * instantly. When replay reaches the activity that was in-progress during the crash, the + * activity is re-dispatched but fails because the in-memory {@code AgentRunContext} is gone. + * The workflow catches this failure and calls + * {@link io.dapr.quarkus.langchain4j.agent.recovery.RecoveryAgentActivity} to re-run the + * agent's ReAct loop from scratch. */ @ApplicationScoped @@ -75,6 +81,7 @@ public WorkflowStub create() { while (true) { LOG.infof("[AgentRun:%s][iter:%d] Waiting for agent-event (replay=%s)", agentRunId, eventIndex, ctx.isReplaying()); + AgentEvent event = ctx.waitForExternalEvent("agent-event", AgentEvent.class).await(); LOG.infof("[AgentRun:%s][iter:%d] Received event: type=%s, callId=%s (replay=%s)", agentRunId, eventIndex, event.type(), event.toolCallId(), ctx.isReplaying()); @@ -85,40 +92,76 @@ public WorkflowStub create() { break; } - if ("tool-call".equals(event.type())) { - LOG.infof("[AgentRun:%s][iter:%d] PRE-callActivity tool-call=%s (replay=%s)", - agentRunId, eventIndex, event.toolName(), ctx.isReplaying()); - ToolCallOutput toolOutput = ctx.callActivity( - "tool-call", - new ToolCallInput(agentRunId, event.toolCallId(), event.toolName(), event.args()), - ToolCallOutput.class).await(); - toolCallOutputs.add(toolOutput); - LOG.infof("[AgentRun:%s][iter:%d] POST-callActivity tool-call=%s → %s", - agentRunId, eventIndex, event.toolName(), toolOutput.result()); - ctx.setCustomStatus(new AgentRunOutput(agentName, toolCallOutputs, llmCallOutputs)); - } + try { + if ("tool-call".equals(event.type())) { + LOG.infof("[AgentRun:%s][iter:%d] PRE-callActivity tool-call=%s (replay=%s)", + agentRunId, eventIndex, event.toolName(), ctx.isReplaying()); + ToolCallOutput toolOutput = ctx.callActivity( + "tool-call", + new ToolCallInput(agentRunId, event.toolCallId(), event.toolName(), event.args()), + ToolCallOutput.class).await(); + toolCallOutputs.add(toolOutput); + LOG.infof("[AgentRun:%s][iter:%d] POST-callActivity tool-call=%s → %s", + agentRunId, eventIndex, event.toolName(), toolOutput.result()); + ctx.setCustomStatus(new AgentRunOutput(agentName, toolCallOutputs, llmCallOutputs, null)); + } - if ("llm-call".equals(event.type())) { - LOG.infof("[AgentRun:%s][iter:%d] PRE-callActivity llm-call=%s (replay=%s)", - agentRunId, eventIndex, event.toolName(), ctx.isReplaying()); - LlmCallOutput llmOutput = ctx.callActivity( - "llm-call", - new LlmCallInput(agentRunId, event.toolCallId(), event.toolName(), event.args()), - LlmCallOutput.class).await(); - llmCallOutputs.add(llmOutput); - LOG.infof("[AgentRun:%s][iter:%d] POST-callActivity llm-call=%s → %s", - agentRunId, eventIndex, event.toolName(), llmOutput.response()); - ctx.setCustomStatus(new AgentRunOutput(agentName, toolCallOutputs, llmCallOutputs)); + if ("llm-call".equals(event.type())) { + LOG.infof("[AgentRun:%s][iter:%d] PRE-callActivity llm-call=%s (replay=%s)", + agentRunId, eventIndex, event.toolName(), ctx.isReplaying()); + LlmCallOutput llmOutput = ctx.callActivity( + "llm-call", + new LlmCallInput(agentRunId, event.toolCallId(), event.toolName(), event.args()), + LlmCallOutput.class).await(); + llmCallOutputs.add(llmOutput); + LOG.infof("[AgentRun:%s][iter:%d] POST-callActivity llm-call=%s → %s", + agentRunId, eventIndex, event.toolName(), llmOutput.response()); + ctx.setCustomStatus(new AgentRunOutput(agentName, toolCallOutputs, llmCallOutputs, null)); + } + } catch (TaskFailedException e) { + // Activity failed — the in-memory AgentRunContext is gone after a crash. + // Enter recovery mode. (Do NOT catch Exception — the framework uses + // OrchestratorBlockedException for internal yield, which must propagate.) + LOG.warnf("[AgentRun:%s][iter:%d] Activity failed (crash recovery): %s", + agentRunId, eventIndex, e.getMessage()); + enterRecovery(ctx, input, agentRunId, agentName, toolCallOutputs, llmCallOutputs); + return; } eventIndex++; } // Set the final output so it is visible in the Dapr workflow dashboard. - ctx.setCustomStatus(new AgentRunOutput(agentName, toolCallOutputs, llmCallOutputs)); + ctx.setCustomStatus(new AgentRunOutput(agentName, toolCallOutputs, llmCallOutputs, null)); }; } + /** + * Enters crash recovery mode: calls {@link io.dapr.quarkus.langchain4j.agent.recovery.RecoveryAgentActivity} + * to re-run the agent's entire ReAct loop from scratch using the original prompt. + */ + private void enterRecovery(io.dapr.workflows.WorkflowContext ctx, + AgentRunInput input, String agentRunId, String agentName, + List toolCallOutputs, List llmCallOutputs) { + + LOG.infof("[AgentRun:%s] Starting recovery — re-running agent=%s from scratch", + agentRunId, agentName); + + RecoveryAgentInput recoveryInput = new RecoveryAgentInput( + agentRunId, agentName, input.systemMessage(), input.userMessage(), + input.toolClassNames()); + + RecoveryAgentOutput recoveryOutput = ctx.callActivity( + "recovery-agent", recoveryInput, RecoveryAgentOutput.class).await(); + + LOG.infof("[AgentRun:%s] Recovery complete — llmCalls=%d, toolCalls=%d", + agentRunId, recoveryOutput.llmCalls(), recoveryOutput.toolCalls()); + + // Set the recovery result as the custom status + ctx.setCustomStatus(new AgentRunOutput(agentName, toolCallOutputs, llmCallOutputs, + recoveryOutput.result())); + } + private static String truncate(String s, int maxLength) { if (s == null) { return null; diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowRuntimeRecorder.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowRuntimeRecorder.java index d53589160b..2f5fe7d003 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowRuntimeRecorder.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowRuntimeRecorder.java @@ -14,6 +14,7 @@ package io.dapr.quarkus.langchain4j.workflow; import io.dapr.quarkus.langchain4j.agent.AgentNameRegistry; +import io.dapr.quarkus.langchain4j.agent.recovery.AgentToolClassRegistry; import io.dapr.workflows.Workflow; import io.dapr.workflows.WorkflowActivity; import io.dapr.workflows.runtime.WorkflowRuntime; @@ -22,6 +23,8 @@ import io.quarkus.runtime.annotations.Recorder; import org.jboss.logging.Logger; +import java.util.List; + /** * Quarkus recorder that registers Dapr workflows and activities at runtime * using the Dapr Java SDK's {@link WorkflowRuntimeBuilder} directly. @@ -94,10 +97,17 @@ public void registerAgentName(String interfaceName, String agentName) { } /** - * Builds the workflow runtime and starts it (non-blocking). + * Registers the tool class names for an agent (from {@code @ToolBox} annotations). + * Used by crash recovery to know which tools to provide when re-running the agent. * - * @param builder the builder runtime value + * @param agentName the agent name from {@code @Agent(name=...)} + * @param toolClassNames fully-qualified class names from {@code @ToolBox} */ + public void registerAgentToolClasses(String agentName, List toolClassNames) { + AgentToolClassRegistry.register(agentName, toolClassNames); + LOG.infof("Registered agent tool classes: %s -> %s", agentName, toolClassNames); + } + /** * Sets the ChatModel provider name for observability. * @@ -108,6 +118,11 @@ public void setChatModelProvider(String providerName) { LOG.infof("ChatModel provider: %s", providerName); } + /** + * Builds the workflow runtime and starts it (non-blocking). + * + * @param builder the builder runtime value + */ public void startRuntime(RuntimeValue builder) { WorkflowRuntime runtime = builder.getValue().build(); runtime.start(false); diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/ConditionalOrchestrationWorkflow.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/ConditionalOrchestrationWorkflow.java index e5d2877d68..0ce12c79de 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/ConditionalOrchestrationWorkflow.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/ConditionalOrchestrationWorkflow.java @@ -13,6 +13,7 @@ package io.dapr.quarkus.langchain4j.workflow.orchestration; +import io.dapr.quarkus.langchain4j.agent.recovery.AgentToolClassRegistry; import io.dapr.quarkus.langchain4j.agent.workflow.AgentRunInput; import io.dapr.quarkus.langchain4j.workflow.DaprAgentServiceUtil; import io.dapr.quarkus.langchain4j.workflow.DaprPlannerRegistry; @@ -48,7 +49,8 @@ public WorkflowStub create() { String agentRunId = input.plannerId() + ":" + i; AgentMetadata metadata = planner.getAgentMetadata(i); AgentRunInput agentInput = new AgentRunInput(agentRunId, metadata.agentName(), - metadata.userMessage(), metadata.systemMessage()); + metadata.userMessage(), metadata.systemMessage(), + AgentToolClassRegistry.get(metadata.agentName())); var childWorkflow = ctx.callChildWorkflow( DaprAgentServiceUtil.agentRunName(metadata.agentName()), agentInput, agentRunId, Void.class); diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/LoopOrchestrationWorkflow.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/LoopOrchestrationWorkflow.java index d4681063de..c2f288f8fc 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/LoopOrchestrationWorkflow.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/LoopOrchestrationWorkflow.java @@ -13,6 +13,7 @@ package io.dapr.quarkus.langchain4j.workflow.orchestration; +import io.dapr.quarkus.langchain4j.agent.recovery.AgentToolClassRegistry; import io.dapr.quarkus.langchain4j.agent.workflow.AgentRunInput; import io.dapr.quarkus.langchain4j.workflow.DaprAgentServiceUtil; import io.dapr.quarkus.langchain4j.workflow.DaprPlannerRegistry; @@ -57,7 +58,8 @@ public WorkflowStub create() { String agentRunId = input.plannerId() + ":" + iter + ":" + i; AgentMetadata metadata = planner.getAgentMetadata(i); AgentRunInput agentInput = new AgentRunInput(agentRunId, metadata.agentName(), - metadata.userMessage(), metadata.systemMessage()); + metadata.userMessage(), metadata.systemMessage(), + AgentToolClassRegistry.get(metadata.agentName())); var childWorkflow = ctx.callChildWorkflow( DaprAgentServiceUtil.agentRunName(metadata.agentName()), agentInput, agentRunId, Void.class); diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/ParallelOrchestrationWorkflow.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/ParallelOrchestrationWorkflow.java index 066f7fde0a..b7b5156181 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/ParallelOrchestrationWorkflow.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/ParallelOrchestrationWorkflow.java @@ -14,6 +14,7 @@ package io.dapr.quarkus.langchain4j.workflow.orchestration; import io.dapr.durabletask.Task; +import io.dapr.quarkus.langchain4j.agent.recovery.AgentToolClassRegistry; import io.dapr.quarkus.langchain4j.agent.workflow.AgentRunInput; import io.dapr.quarkus.langchain4j.workflow.DaprAgentServiceUtil; import io.dapr.quarkus.langchain4j.workflow.DaprPlannerRegistry; @@ -51,7 +52,8 @@ public WorkflowStub create() { String agentRunId = input.plannerId() + ":" + i; AgentMetadata metadata = planner.getAgentMetadata(i); AgentRunInput agentInput = new AgentRunInput(agentRunId, metadata.agentName(), - metadata.userMessage(), metadata.systemMessage()); + metadata.userMessage(), metadata.systemMessage(), + AgentToolClassRegistry.get(metadata.agentName())); // Start AgentRunWorkflow as a child workflow with agent-specific name childWorkflows.add(ctx.callChildWorkflow( diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/SequentialOrchestrationWorkflow.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/SequentialOrchestrationWorkflow.java index ef69f75334..2bb9aad29c 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/SequentialOrchestrationWorkflow.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/SequentialOrchestrationWorkflow.java @@ -13,6 +13,7 @@ package io.dapr.quarkus.langchain4j.workflow.orchestration; +import io.dapr.quarkus.langchain4j.agent.recovery.AgentToolClassRegistry; import io.dapr.quarkus.langchain4j.agent.workflow.AgentRunInput; import io.dapr.quarkus.langchain4j.workflow.DaprAgentServiceUtil; import io.dapr.quarkus.langchain4j.workflow.DaprPlannerRegistry; @@ -43,7 +44,8 @@ public WorkflowStub create() { String agentRunId = input.plannerId() + ":" + i; AgentMetadata metadata = planner.getAgentMetadata(i); AgentRunInput agentInput = new AgentRunInput(agentRunId, metadata.agentName(), - metadata.userMessage(), metadata.systemMessage()); + metadata.userMessage(), metadata.systemMessage(), + AgentToolClassRegistry.get(metadata.agentName())); // Start AgentRunWorkflow as child with agent-specific .agent-run name var childWorkflow = ctx.callChildWorkflow( From 5aa83e0abb87d427544d8125f3e9eb7ef933fe92 Mon Sep 17 00:00:00 2001 From: Javier Aliaga Date: Tue, 9 Jun 2026 16:22:25 +0200 Subject: [PATCH 05/21] =?UTF-8?q?fix(quarkus):=20improve=20chatModel=20obs?= =?UTF-8?q?ervability=20=E2=80=94=20fix=20null=20response=20and=20prompt?= =?UTF-8?q?=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DaprChatModelDecorator: replace String.valueOf(messages) with compact formatMessage() that omits null fields and formats each message type (SystemMessage, UserMessage, AiMessage with tool_calls, ToolResult). LlmCallActivity: when aiMessage.text() is null (tool execution requests), format as "[tool_calls: name1, name2]" instead of returning "null". Signed-off-by: Javier Aliaga --- quarkus/README.md | 4 +- .../agent/DaprChatModelDecorator.java | 76 ++++++++++++++++++- .../agent/activities/LlmCallActivity.java | 21 ++++- 3 files changed, 96 insertions(+), 5 deletions(-) diff --git a/quarkus/README.md b/quarkus/README.md index f0bf31f151..9a6c8ccac2 100644 --- a/quarkus/README.md +++ b/quarkus/README.md @@ -136,13 +136,13 @@ kill -9 mvn quarkus:dev ``` -In the Dapr dashboard, completed agents show cached results. The crashed agent shows "Recovery timeout" in logs, then re-runs and completes. +In the Dapr dashboard, completed agents show cached results. The crashed agent is detected via `TaskFailedException`, then re-runs and completes. ### Key classes | Class | Role | |-------|------| -| `AgentRunWorkflow` | Detects missing AiServices thread via timer, triggers recovery | +| `AgentRunWorkflow` | Catches `TaskFailedException` on activity failure, triggers recovery | | `RecoveryAgentActivity` | Self-contained ReAct loop: ChatModel.chat() + tool dispatch | | `ToolRegistry` | CDI bean that discovers @Tool methods at startup for recovery | | `AgentToolClassRegistry` | Maps agent names to their @ToolBox classes (populated at build time) | diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelDecorator.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelDecorator.java index 5fc4d865a2..7e70e72959 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelDecorator.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelDecorator.java @@ -218,13 +218,85 @@ private String extractPrompt(ChatRequest request) { return null; } try { - Object messages = request.getClass().getMethod("messages").invoke(request); - return String.valueOf(messages); + List messages = (List) request.getClass().getMethod("messages").invoke(request); + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < messages.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(formatMessage(messages.get(i))); + } + sb.append("]"); + return sb.toString(); } catch (Exception ex) { return String.valueOf(request); } } + /** + * Formats a single ChatMessage into a compact, human-readable string + * that omits null fields. Handles SystemMessage, UserMessage, AiMessage, + * and ToolExecutionResultMessage. + */ + @SuppressWarnings("unchecked") + private String formatMessage(Object msg) { + String type = msg.getClass().getSimpleName(); + try { + switch (type) { + case "SystemMessage": { + Object text = msg.getClass().getMethod("text").invoke(msg); + return "SystemMessage: " + quote(text); + } + case "UserMessage": { + Object text = msg.getClass().getMethod("singleText").invoke(msg); + return "UserMessage: " + quote(text); + } + case "AiMessage": { + Object text = msg.getClass().getMethod("text").invoke(msg); + if (text != null) { + return "AiMessage: " + quote(text); + } + Object toolReqs = msg.getClass().getMethod("toolExecutionRequests").invoke(msg); + if (toolReqs instanceof List list && !list.isEmpty()) { + StringBuilder sb = new StringBuilder("AiMessage: tool_calls=["); + for (int i = 0; i < list.size(); i++) { + if (i > 0) { + sb.append(", "); + } + Object req = list.get(i); + Object name = req.getClass().getMethod("name").invoke(req); + Object args = req.getClass().getMethod("arguments").invoke(req); + sb.append(name).append("(").append(args).append(")"); + } + sb.append("]"); + return sb.toString(); + } + return "AiMessage: (empty)"; + } + case "ToolExecutionResultMessage": { + Object toolName = msg.getClass().getMethod("toolName").invoke(msg); + Object text = msg.getClass().getMethod("text").invoke(msg); + return "ToolResult[" + toolName + "]: " + quote(text); + } + default: + return type + ": " + msg; + } + } catch (ReflectiveOperationException e) { + return type + ": " + msg; + } + } + + private static String quote(Object value) { + if (value == null) { + return "null"; + } + String s = String.valueOf(value); + if (s.length() > 200) { + s = s.substring(0, 200) + "..."; + } + return "\"" + s + "\""; + } + /** * Extracts the last (most recent) user message text from the {@code ChatRequest}. * Uses reflection to remain decoupled from specific langchain4j internals. diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallActivity.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallActivity.java index df8eca6e9e..b7ba0bcf60 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallActivity.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallActivity.java @@ -127,7 +127,26 @@ private String extractResponseText(Object result) { Object aiMessage = result.getClass().getMethod("aiMessage").invoke(result); if (aiMessage != null) { Object text = aiMessage.getClass().getMethod("text").invoke(aiMessage); - return String.valueOf(text); + if (text != null) { + return String.valueOf(text); + } + // text is null — check if the LLM returned tool execution requests + Object toolRequests = aiMessage.getClass() + .getMethod("toolExecutionRequests").invoke(aiMessage); + if (toolRequests instanceof java.util.List list && !list.isEmpty()) { + StringBuilder sb = new StringBuilder("[tool_calls: "); + for (int i = 0; i < list.size(); i++) { + if (i > 0) { + sb.append(", "); + } + Object req = list.get(i); + Object name = req.getClass().getMethod("name").invoke(req); + sb.append(name); + } + sb.append("]"); + return sb.toString(); + } + return null; } } catch (ReflectiveOperationException ignored) { // Not a ChatResponse or missing expected methods — fall through. From 20389f30902335b34a35c98add257e5989a37a35 Mon Sep 17 00:00:00 2001 From: Javier Aliaga Date: Wed, 10 Jun 2026 10:27:00 +0200 Subject: [PATCH 06/21] fix(quarkus): harden agent runtime per code review - Recovery falls back to all tools when the @ToolBox class list is empty - RecoveryAgentActivity throws on max iterations instead of returning a sentinel string that downstream treats as a valid agent result - Warn when synthetic parameter names (arg0...) are detected in tool arg parsing (missing -parameters compiler flag) - Orchestration workflows release the blocked planner thread on real failures while letting OrchestratorBlockedException propagate untouched - Generated agent decorator clears DaprAgentMetadataHolder after the fallback delegate call instead of leaking the ThreadLocal - LLM/tool call futures use a configurable timeout (dapr.agentic.call-timeout-minutes, default 10) instead of waiting forever - ToolRegistry sets non-public @Tool methods accessible for recovery - DaprChatModelWrapper fails fast with a descriptive error when no ChatModel provider is configured Signed-off-by: Javier Aliaga --- .../deployment/DaprAgenticProcessor.java | 7 ++ .../agent/DaprChatModelDecorator.java | 17 +++- .../agent/DaprChatModelWrapper.java | 10 +++ .../agent/DaprToolCallInterceptor.java | 11 ++- .../agent/recovery/RecoveryAgentActivity.java | 20 +++-- .../agent/recovery/ToolRegistry.java | 4 + .../ConditionalOrchestrationWorkflow.java | 50 +++++++----- .../LoopOrchestrationWorkflow.java | 78 +++++++++++-------- .../ParallelOrchestrationWorkflow.java | 62 +++++++++------ .../SequentialOrchestrationWorkflow.java | 46 +++++++---- 10 files changed, 199 insertions(+), 106 deletions(-) diff --git a/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java b/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java index 4b9df807db..6a74fab991 100644 --- a/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java +++ b/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java @@ -571,6 +571,9 @@ private void generateDecoratedAgentMethod(ClassCreator cc, MethodInfo method, ? activateTry.load(systemMessage) : activateTry.loadNull()); // If activation fails (no request scope), delegate directly without Dapr routing. + // NOTE: the metadata ThreadLocal must stay set DURING the fallback delegate call — + // DaprChatModelDecorator.tryLazyActivate() reads it to recover the real agent name + // and messages. Clear it only AFTER the delegate call returns. CatchBlockCreator activateCatch = activateTry.addCatch(Throwable.class); { ResultHandle delFallback = activateCatch.readInstanceField( @@ -582,10 +585,14 @@ private void generateDecoratedAgentMethod(ClassCreator cc, MethodInfo method, if (isVoid) { activateCatch.invokeInterfaceMethod( MethodDescriptor.of(method), delFallback, fallbackParams); + activateCatch.invokeStaticMethod( + MethodDescriptor.ofMethod(DaprAgentMetadataHolder.class, "clear", void.class)); activateCatch.returnVoid(); } else { ResultHandle fallbackResult = activateCatch.invokeInterfaceMethod( MethodDescriptor.of(method), delFallback, fallbackParams); + activateCatch.invokeStaticMethod( + MethodDescriptor.ofMethod(DaprAgentMetadataHolder.class, "clear", void.class)); activateCatch.returnValue(fallbackResult); } } diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelDecorator.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelDecorator.java index 7e70e72959..c60ad0217e 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelDecorator.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelDecorator.java @@ -26,12 +26,14 @@ import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; import jakarta.interceptor.Interceptor; +import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.logging.Logger; import java.lang.reflect.Method; import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; /** * CDI Decorator that routes {@code ChatModel.chat(ChatRequest)} calls through a Dapr * Workflow Activity when executing inside an active agent run. @@ -89,6 +91,14 @@ public class DaprChatModelDecorator implements ChatModel { @Inject Instance lifecycleManager; + /** + * Maximum time to wait for the routed LLM call to complete via the workflow activity. + * Configurable because large local models (e.g., Ollama) can have long generation times. + */ + @Inject + @ConfigProperty(name = "dapr.agentic.call-timeout-minutes", defaultValue = "10") + long callTimeoutMinutes; + /** * Explicit delegation for the {@code doChat()} template method. * @@ -153,9 +163,10 @@ public ChatResponse chat(ChatRequest request) { agentRunId, llmCallId); workflowClient.raiseEvent(agentRunId, "agent-event", new AgentEvent("llm-call", llmCallId, "chat", prompt)); - LOG.infof("[AgentRun:%s][LlmCall:%s] POST-raiseEvent — blocking on future.join()", - agentRunId, llmCallId); - ChatResponse response = (ChatResponse) future.join(); + LOG.infof("[AgentRun:%s][LlmCall:%s] POST-raiseEvent — blocking on future (timeout %d min)", + agentRunId, llmCallId, callTimeoutMinutes); + ChatResponse response = (ChatResponse) future + .orTimeout(callTimeoutMinutes, TimeUnit.MINUTES).join(); LOG.infof("[AgentRun:%s][LlmCall:%s] POST-join — LLM result received", agentRunId, llmCallId); return response; diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelWrapper.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelWrapper.java index ba4137fd4f..c9c3f96bb5 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelWrapper.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelWrapper.java @@ -82,11 +82,21 @@ void initDelegate() { @Override public ChatResponse doChat(ChatRequest request) { + ensureDelegate(); return delegate.doChat(request); } @Override public ChatResponse chat(ChatRequest request) { + ensureDelegate(); return delegate.chat(request); } + + private void ensureDelegate() { + if (delegate == null) { + throw new IllegalStateException("No ChatModel provider configured. " + + "Add a LangChain4j provider dependency (e.g., quarkus-langchain4j-openai, " + + "quarkus-langchain4j-ollama) to your project."); + } + } } diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprToolCallInterceptor.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprToolCallInterceptor.java index 58b174106a..e8c2c646fd 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprToolCallInterceptor.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprToolCallInterceptor.java @@ -21,11 +21,13 @@ import jakarta.interceptor.AroundInvoke; import jakarta.interceptor.Interceptor; import jakarta.interceptor.InvocationContext; +import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.logging.Logger; import java.util.Arrays; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; /** * CDI interceptor that routes {@code @Tool}-annotated method calls through a Dapr Workflow * Activity when executing inside a Dapr-backed agent run. @@ -71,6 +73,13 @@ public class DaprToolCallInterceptor { @Inject Instance lifecycleManager; + /** + * Maximum time to wait for the routed tool call to complete via the workflow activity. + */ + @Inject + @ConfigProperty(name = "dapr.agentic.call-timeout-minutes", defaultValue = "10") + long callTimeoutMinutes; + /** * Intercepts tool-annotated method calls and routes them through Dapr Workflow. * @@ -123,7 +132,7 @@ public Object intercept(InvocationContext ctx) throws Exception { new AgentEvent("tool-call", toolCallId, ctx.getMethod().getName(), args)); // Block the agent thread until ToolCallActivity completes the tool execution. - return future.join(); + return future.orTimeout(callTimeoutMinutes, TimeUnit.MINUTES).join(); } /** diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/RecoveryAgentActivity.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/RecoveryAgentActivity.java index cd8a4c4253..32c8008955 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/RecoveryAgentActivity.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/RecoveryAgentActivity.java @@ -68,9 +68,10 @@ public Object run(WorkflowActivityContext ctx) { ToolRegistry toolRegistry = Arc.container().instance(ToolRegistry.class).get(); // Build tool specifications from the agent's @ToolBox classes - List toolSpecs = input.toolClassNames() != null - ? toolRegistry.getToolSpecsForClasses(input.toolClassNames()) - : toolRegistry.getAllToolSpecs(); + List toolSpecs = + input.toolClassNames() != null && !input.toolClassNames().isEmpty() + ? toolRegistry.getToolSpecsForClasses(input.toolClassNames()) + : toolRegistry.getAllToolSpecs(); LOG.infof("[Recovery:%s] Available tools: %d", input.agentRunId(), toolSpecs.size()); @@ -150,12 +151,11 @@ public Object run(WorkflowActivityContext ctx) { } } - // Max iterations reached - LOG.warnf("[Recovery:%s] Max iterations (%d) reached — returning last response", + // Max iterations reached — fail explicitly rather than returning a sentinel string + LOG.warnf("[Recovery:%s] Max iterations (%d) reached without text response", input.agentRunId(), MAX_ITERATIONS); - return new RecoveryAgentOutput(input.agentName(), - "Recovery reached max iterations (" + MAX_ITERATIONS + ")", - llmCalls, toolCalls); + throw new RuntimeException("Recovery agent '" + input.agentName() + + "' reached max iterations (" + MAX_ITERATIONS + ") without producing a result"); } /** @@ -185,6 +185,10 @@ private Object[] parseToolArguments(ToolRegistry toolRegistry, String toolName, // how LangChain4j generates ToolSpecification parameter names. The @P annotation // value is the description, not the name. String paramName = params[i].getName(); + if (paramName.matches("arg\\d+") && params.length > 1) { + LOG.warnf("Tool '%s' parameter '%s' appears synthetic — " + + "ensure -parameters compiler flag is set for tool classes", toolName, paramName); + } Object value = argsMap.get(paramName); if (value == null && argsMap.size() == 1 && params.length == 1) { // Single-param tool: use whatever key the LLM sent diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/ToolRegistry.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/ToolRegistry.java index 2d47c3934c..2d69b2fc9a 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/ToolRegistry.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/recovery/ToolRegistry.java @@ -56,6 +56,9 @@ void init() { 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; @@ -170,6 +173,7 @@ private Method findToolMethod(Class clazz, String toolName) { // @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; } } diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/ConditionalOrchestrationWorkflow.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/ConditionalOrchestrationWorkflow.java index 0ce12c79de..69f910291b 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/ConditionalOrchestrationWorkflow.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/ConditionalOrchestrationWorkflow.java @@ -13,6 +13,7 @@ package io.dapr.quarkus.langchain4j.workflow.orchestration; +import io.dapr.durabletask.interruption.OrchestratorBlockedException; import io.dapr.quarkus.langchain4j.agent.recovery.AgentToolClassRegistry; import io.dapr.quarkus.langchain4j.agent.workflow.AgentRunInput; import io.dapr.quarkus.langchain4j.workflow.DaprAgentServiceUtil; @@ -41,28 +42,39 @@ public WorkflowStub create() { OrchestrationInput input = ctx.getInput(OrchestrationInput.class); DaprWorkflowPlanner planner = DaprPlannerRegistry.get(input.plannerId()); - for (int i = 0; i < input.agentCount(); i++) { - boolean shouldExec = ctx.callActivity("condition-check", - new ConditionCheckInput(input.plannerId(), i), - Boolean.class).await(); - if (shouldExec) { - String agentRunId = input.plannerId() + ":" + i; - AgentMetadata metadata = planner.getAgentMetadata(i); - AgentRunInput agentInput = new AgentRunInput(agentRunId, metadata.agentName(), - metadata.userMessage(), metadata.systemMessage(), - AgentToolClassRegistry.get(metadata.agentName())); + try { + for (int i = 0; i < input.agentCount(); i++) { + boolean shouldExec = ctx.callActivity("condition-check", + new ConditionCheckInput(input.plannerId(), i), + Boolean.class).await(); + if (shouldExec) { + String agentRunId = input.plannerId() + ":" + i; + AgentMetadata metadata = planner.getAgentMetadata(i); + AgentRunInput agentInput = new AgentRunInput(agentRunId, metadata.agentName(), + metadata.userMessage(), metadata.systemMessage(), + AgentToolClassRegistry.get(metadata.agentName())); - var childWorkflow = ctx.callChildWorkflow( - DaprAgentServiceUtil.agentRunName(metadata.agentName()), agentInput, agentRunId, Void.class); - // Submit agent to planner (non-blocking activity — returns immediately) - ctx.callActivity("agent-call", - new AgentExecInput(input.plannerId(), i, agentRunId), Void.class).await(); - // Wait for agent completion (signaled by planner's nextAction) - ctx.waitForExternalEvent("agent-complete-" + agentRunId, Void.class).await(); - childWorkflow.await(); + var childWorkflow = ctx.callChildWorkflow( + DaprAgentServiceUtil.agentRunName(metadata.agentName()), agentInput, agentRunId, Void.class); + // Submit agent to planner (non-blocking activity — returns immediately) + ctx.callActivity("agent-call", + new AgentExecInput(input.plannerId(), i, agentRunId), Void.class).await(); + // Wait for agent completion (signaled by planner's nextAction) + ctx.waitForExternalEvent("agent-complete-" + agentRunId, Void.class).await(); + childWorkflow.await(); + } } + } catch (OrchestratorBlockedException blocked) { + // Framework yield — must propagate untouched (do NOT signal the planner). + throw blocked; + } catch (RuntimeException e) { + // Real failure (e.g. TaskFailedException) — release the blocked planner thread. + if (planner != null) { + planner.signalWorkflowComplete(); + } + throw e; } - // Signal planner that the workflow has completed + // Normal completion — runs only on the final replay pass. if (planner != null) { planner.signalWorkflowComplete(); } diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/LoopOrchestrationWorkflow.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/LoopOrchestrationWorkflow.java index c2f288f8fc..a1c2bd59cf 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/LoopOrchestrationWorkflow.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/LoopOrchestrationWorkflow.java @@ -13,6 +13,7 @@ package io.dapr.quarkus.langchain4j.workflow.orchestration; +import io.dapr.durabletask.interruption.OrchestratorBlockedException; import io.dapr.quarkus.langchain4j.agent.recovery.AgentToolClassRegistry; import io.dapr.quarkus.langchain4j.agent.workflow.AgentRunInput; import io.dapr.quarkus.langchain4j.workflow.DaprAgentServiceUtil; @@ -42,46 +43,57 @@ public WorkflowStub create() { OrchestrationInput input = ctx.getInput(OrchestrationInput.class); DaprWorkflowPlanner planner = DaprPlannerRegistry.get(input.plannerId()); - for (int iter = 0; iter < input.maxIterations(); iter++) { - // Check exit condition at loop start (unless configured to check at end) - if (!input.testExitAtLoopEnd()) { - boolean exit = ctx.callActivity("exit-condition-check", - new ExitConditionCheckInput(input.plannerId(), iter), - Boolean.class).await(); - if (exit) { - break; + try { + for (int iter = 0; iter < input.maxIterations(); iter++) { + // Check exit condition at loop start (unless configured to check at end) + if (!input.testExitAtLoopEnd()) { + boolean exit = ctx.callActivity("exit-condition-check", + new ExitConditionCheckInput(input.plannerId(), iter), + Boolean.class).await(); + if (exit) { + break; + } } - } - // Execute all agents sequentially within this iteration - for (int i = 0; i < input.agentCount(); i++) { - String agentRunId = input.plannerId() + ":" + iter + ":" + i; - AgentMetadata metadata = planner.getAgentMetadata(i); - AgentRunInput agentInput = new AgentRunInput(agentRunId, metadata.agentName(), - metadata.userMessage(), metadata.systemMessage(), - AgentToolClassRegistry.get(metadata.agentName())); + // Execute all agents sequentially within this iteration + for (int i = 0; i < input.agentCount(); i++) { + String agentRunId = input.plannerId() + ":" + iter + ":" + i; + AgentMetadata metadata = planner.getAgentMetadata(i); + AgentRunInput agentInput = new AgentRunInput(agentRunId, metadata.agentName(), + metadata.userMessage(), metadata.systemMessage(), + AgentToolClassRegistry.get(metadata.agentName())); - var childWorkflow = ctx.callChildWorkflow( - DaprAgentServiceUtil.agentRunName(metadata.agentName()), agentInput, agentRunId, Void.class); - // Submit agent to planner (non-blocking activity -- returns immediately) - ctx.callActivity("agent-call", - new AgentExecInput(input.plannerId(), i, agentRunId), Void.class).await(); - // Wait for agent completion (signaled by planner's nextAction) - ctx.waitForExternalEvent("agent-complete-" + agentRunId, Void.class).await(); - childWorkflow.await(); - } + var childWorkflow = ctx.callChildWorkflow( + DaprAgentServiceUtil.agentRunName(metadata.agentName()), agentInput, agentRunId, Void.class); + // Submit agent to planner (non-blocking activity -- returns immediately) + ctx.callActivity("agent-call", + new AgentExecInput(input.plannerId(), i, agentRunId), Void.class).await(); + // Wait for agent completion (signaled by planner's nextAction) + ctx.waitForExternalEvent("agent-complete-" + agentRunId, Void.class).await(); + childWorkflow.await(); + } - // Check exit condition at loop end (if configured) - if (input.testExitAtLoopEnd()) { - boolean exit = ctx.callActivity("exit-condition-check", - new ExitConditionCheckInput(input.plannerId(), iter), - Boolean.class).await(); - if (exit) { - break; + // Check exit condition at loop end (if configured) + if (input.testExitAtLoopEnd()) { + boolean exit = ctx.callActivity("exit-condition-check", + new ExitConditionCheckInput(input.plannerId(), iter), + Boolean.class).await(); + if (exit) { + break; + } } } + } catch (OrchestratorBlockedException blocked) { + // Framework yield — must propagate untouched (do NOT signal the planner). + throw blocked; + } catch (RuntimeException e) { + // Real failure (e.g. TaskFailedException) — release the blocked planner thread. + if (planner != null) { + planner.signalWorkflowComplete(); + } + throw e; } - // Signal planner that the workflow has completed + // Normal completion — runs only on the final replay pass. if (planner != null) { planner.signalWorkflowComplete(); } diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/ParallelOrchestrationWorkflow.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/ParallelOrchestrationWorkflow.java index b7b5156181..f38751a1ed 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/ParallelOrchestrationWorkflow.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/ParallelOrchestrationWorkflow.java @@ -14,6 +14,7 @@ package io.dapr.quarkus.langchain4j.workflow.orchestration; import io.dapr.durabletask.Task; +import io.dapr.durabletask.interruption.OrchestratorBlockedException; import io.dapr.quarkus.langchain4j.agent.recovery.AgentToolClassRegistry; import io.dapr.quarkus.langchain4j.agent.workflow.AgentRunInput; import io.dapr.quarkus.langchain4j.workflow.DaprAgentServiceUtil; @@ -45,33 +46,44 @@ public WorkflowStub create() { OrchestrationInput input = ctx.getInput(OrchestrationInput.class); DaprWorkflowPlanner planner = DaprPlannerRegistry.get(input.plannerId()); - List> childWorkflows = new ArrayList<>(); - List> submitTasks = new ArrayList<>(); - List> completionEvents = new ArrayList<>(); - for (int i = 0; i < input.agentCount(); i++) { - String agentRunId = input.plannerId() + ":" + i; - AgentMetadata metadata = planner.getAgentMetadata(i); - AgentRunInput agentInput = new AgentRunInput(agentRunId, metadata.agentName(), - metadata.userMessage(), metadata.systemMessage(), - AgentToolClassRegistry.get(metadata.agentName())); + try { + List> childWorkflows = new ArrayList<>(); + List> submitTasks = new ArrayList<>(); + List> completionEvents = new ArrayList<>(); + for (int i = 0; i < input.agentCount(); i++) { + String agentRunId = input.plannerId() + ":" + i; + AgentMetadata metadata = planner.getAgentMetadata(i); + AgentRunInput agentInput = new AgentRunInput(agentRunId, metadata.agentName(), + metadata.userMessage(), metadata.systemMessage(), + AgentToolClassRegistry.get(metadata.agentName())); - // Start AgentRunWorkflow as a child workflow with agent-specific name - childWorkflows.add(ctx.callChildWorkflow( - DaprAgentServiceUtil.agentRunName(metadata.agentName()), agentInput, agentRunId, Void.class)); - // Submit agent to planner (non-blocking activity -- returns immediately) - submitTasks.add(ctx.callActivity("agent-call", - new AgentExecInput(input.plannerId(), i, agentRunId), Void.class)); - // Register event listener for agent completion (signaled by planner's nextAction) - completionEvents.add( - ctx.waitForExternalEvent("agent-complete-" + agentRunId, Void.class)); + // Start AgentRunWorkflow as a child workflow with agent-specific name + childWorkflows.add(ctx.callChildWorkflow( + DaprAgentServiceUtil.agentRunName(metadata.agentName()), agentInput, agentRunId, Void.class)); + // Submit agent to planner (non-blocking activity -- returns immediately) + submitTasks.add(ctx.callActivity("agent-call", + new AgentExecInput(input.plannerId(), i, agentRunId), Void.class)); + // Register event listener for agent completion (signaled by planner's nextAction) + completionEvents.add( + ctx.waitForExternalEvent("agent-complete-" + agentRunId, Void.class)); + } + // Wait for all agents to be submitted + ctx.allOf(submitTasks).await(); + // Wait for all agents to complete (planner raises events after each agent finishes) + ctx.allOf(completionEvents).await(); + // Wait for all child AgentRunWorkflows to finish (they received "done" from planner) + ctx.allOf(childWorkflows).await(); + } catch (OrchestratorBlockedException blocked) { + // Framework yield — must propagate untouched (do NOT signal the planner). + throw blocked; + } catch (RuntimeException e) { + // Real failure (e.g. TaskFailedException) — release the blocked planner thread. + if (planner != null) { + planner.signalWorkflowComplete(); + } + throw e; } - // Wait for all agents to be submitted - ctx.allOf(submitTasks).await(); - // Wait for all agents to complete (planner raises events after each agent finishes) - ctx.allOf(completionEvents).await(); - // Wait for all child AgentRunWorkflows to finish (they received "done" from planner) - ctx.allOf(childWorkflows).await(); - // Signal planner that the workflow has completed + // Normal completion — runs only on the final replay pass. if (planner != null) { planner.signalWorkflowComplete(); } diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/SequentialOrchestrationWorkflow.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/SequentialOrchestrationWorkflow.java index 2bb9aad29c..9e9a6e8302 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/SequentialOrchestrationWorkflow.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/SequentialOrchestrationWorkflow.java @@ -13,6 +13,7 @@ package io.dapr.quarkus.langchain4j.workflow.orchestration; +import io.dapr.durabletask.interruption.OrchestratorBlockedException; import io.dapr.quarkus.langchain4j.agent.recovery.AgentToolClassRegistry; import io.dapr.quarkus.langchain4j.agent.workflow.AgentRunInput; import io.dapr.quarkus.langchain4j.workflow.DaprAgentServiceUtil; @@ -40,25 +41,36 @@ public WorkflowStub create() { OrchestrationInput input = ctx.getInput(OrchestrationInput.class); DaprWorkflowPlanner planner = DaprPlannerRegistry.get(input.plannerId()); - for (int i = 0; i < input.agentCount(); i++) { - String agentRunId = input.plannerId() + ":" + i; - AgentMetadata metadata = planner.getAgentMetadata(i); - AgentRunInput agentInput = new AgentRunInput(agentRunId, metadata.agentName(), - metadata.userMessage(), metadata.systemMessage(), - AgentToolClassRegistry.get(metadata.agentName())); + try { + for (int i = 0; i < input.agentCount(); i++) { + String agentRunId = input.plannerId() + ":" + i; + AgentMetadata metadata = planner.getAgentMetadata(i); + AgentRunInput agentInput = new AgentRunInput(agentRunId, metadata.agentName(), + metadata.userMessage(), metadata.systemMessage(), + AgentToolClassRegistry.get(metadata.agentName())); - // Start AgentRunWorkflow as child with agent-specific .agent-run name - var childWorkflow = ctx.callChildWorkflow( - DaprAgentServiceUtil.agentRunName(metadata.agentName()), - agentInput, agentRunId, Void.class); - // Submit agent to planner (non-blocking activity — returns immediately) - ctx.callActivity("agent-call", - new AgentExecInput(input.plannerId(), i, agentRunId), Void.class).await(); - // Wait for agent completion (signaled by planner's nextAction) - ctx.waitForExternalEvent("agent-complete-" + agentRunId, Void.class).await(); - childWorkflow.await(); + // Start AgentRunWorkflow as child with agent-specific .agent-run name + var childWorkflow = ctx.callChildWorkflow( + DaprAgentServiceUtil.agentRunName(metadata.agentName()), + agentInput, agentRunId, Void.class); + // Submit agent to planner (non-blocking activity — returns immediately) + ctx.callActivity("agent-call", + new AgentExecInput(input.plannerId(), i, agentRunId), Void.class).await(); + // Wait for agent completion (signaled by planner's nextAction) + ctx.waitForExternalEvent("agent-complete-" + agentRunId, Void.class).await(); + childWorkflow.await(); + } + } catch (OrchestratorBlockedException blocked) { + // Framework yield — must propagate untouched (do NOT signal the planner). + throw blocked; + } catch (RuntimeException e) { + // Real failure (e.g. TaskFailedException) — release the blocked planner thread. + if (planner != null) { + planner.signalWorkflowComplete(); + } + throw e; } - // Signal planner that the workflow has completed + // Normal completion — runs only on the final replay pass. if (planner != null) { planner.signalWorkflowComplete(); } From 6205d979f901263c7d12eaa2b29548085d8bf1b4 Mon Sep 17 00:00:00 2001 From: Javier Aliaga Date: Wed, 10 Jun 2026 10:27:08 +0200 Subject: [PATCH 07/21] ci: pass release version via env to prevent shell injection Move ${{ inputs.rel_version }} from inline shell interpolation to the step's env block so the value is read as data, not substituted into the script before shell evaluation. Signed-off-by: Javier Aliaga --- .github/workflows/quarkus-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/quarkus-release.yml b/.github/workflows/quarkus-release.yml index 0b86c6259c..92228ce5a9 100644 --- a/.github/workflows/quarkus-release.yml +++ b/.github/workflows/quarkus-release.yml @@ -29,9 +29,9 @@ jobs: - name: Update quarkus version and tag env: GITHUB_TOKEN: ${{ secrets.DAPR_BOT_TOKEN }} + REL_VERSION: ${{ inputs.rel_version }} run: | set -ue - REL_VERSION="${{ inputs.rel_version }}" git config user.email "daprweb@microsoft.com" git config user.name "Dapr Bot" From 7331dd1c698c20efad816f208ab0a21cfd6e1bcc Mon Sep 17 00:00:00 2001 From: Javier Aliaga Date: Wed, 10 Jun 2026 12:29:03 +0200 Subject: [PATCH 08/21] fix(quarkus): align build with SDK 1.19.0-SNAPSHOT from master - Bump parent and dapr.sdk.version from 1.18.0 to 1.19.0-SNAPSHOT: master bumped the root POM, so the quarkus parent failed to resolve via relativePath and every CI job died at reactor load - Add the central snapshots repository: the quarkus CI job builds this tree standalone, so SDK SNAPSHOT artifacts must be resolvable (remove when dapr.sdk.version is pinned to a release) - Pin testcontainers to 2.0.3 (quarkus-bom aligned): the root POM's 2.0.5 requires a newer docker-java than quarkus-bom's 3.7.0, breaking dev services with NoClassDefFoundError: ImageHistoryCmd Signed-off-by: Javier Aliaga --- quarkus/pom.xml | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/quarkus/pom.xml b/quarkus/pom.xml index 99b9ce93ea..5224613e04 100644 --- a/quarkus/pom.xml +++ b/quarkus/pom.xml @@ -7,7 +7,7 @@ io.dapr dapr-sdk-parent - 1.18.0-SNAPSHOT + 1.19.0-SNAPSHOT ../pom.xml io.dapr.quarkus @@ -33,9 +33,25 @@ 2.5.0 1.7.1 - 1.18.0-SNAPSHOT + 1.19.0-SNAPSHOT + + + + sonatype-snapshots + https://central.sonatype.com/repository/maven-snapshots/ + + false + + + true + + + + @@ -61,6 +77,20 @@ commons-io 2.20.0 + + + org.testcontainers + testcontainers + 2.0.3 + + + org.testcontainers + testcontainers-junit-jupiter + 2.0.3 + - 1.19.0-SNAPSHOT + 1.18.0-rc-3 - - - - sonatype-snapshots - https://central.sonatype.com/repository/maven-snapshots/ - - false - - - true - - - - diff --git a/quarkus/quarkus-agentic-dapr-agents-registry/src/test/resources/application.properties b/quarkus/quarkus-agentic-dapr-agents-registry/src/test/resources/application.properties index f181e2c1ed..41351d4413 100644 --- a/quarkus/quarkus-agentic-dapr-agents-registry/src/test/resources/application.properties +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/test/resources/application.properties @@ -1,6 +1,9 @@ # Dapr Dev Services with PostgreSQL state store (dashboard enabled = uses PostgreSQL) quarkus.dapr.devservices.enabled=true quarkus.dapr.devservices.dashboard.enabled=true +# Match the Dapr runtime pinned by testcontainers-dapr 1.18.0-rc-3 (canonical +# daprd/placement/scheduler images are rc.4; Testcontainers rejects other tags) +quarkus.dapr.devservices.daprd-image=daprio/daprd:1.18.0-rc.4 # Load gRPC/protobuf/Jackson parent-first: the Dapr SDK uses them across the # @QuarkusTest classloader boundary (dev services + workflow runtime), otherwise From 8f0284dda43c701865f38d565d475d8b7bb42005 Mon Sep 17 00:00:00 2001 From: Javier Aliaga Date: Wed, 10 Jun 2026 13:50:15 +0200 Subject: [PATCH 11/21] feat(quarkus): checkpoint LangChain4j agentic scopes to Dapr state store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements LangChain4j's AgenticScopeStore SPI over the Dapr state API — the LangChain4j equivalent of a LangGraph checkpointer. When enabled, AgenticScopeRegistry persists every scope update (shared state, conversation context, agent invocations), so agentic workflow state survives restarts and is shareable across replicas. - DaprAgenticScopeStore: save/load/delete via AgenticScopeSerializer (LangChain4j's own Jackson codec); companion _index key for getAllKeys - AgenticScopeStoreInitializer: opt-in via dapr.agentic.scope-store.enabled / dapr.agentic.scope-store.name, registered with AgenticScopePersister at startup Signed-off-by: Javier Aliaga --- quarkus/README.md | 17 ++ .../deployment/DaprAgenticProcessor.java | 3 + .../scope/AgenticScopeStoreInitializer.java | 60 +++++++ .../scope/DaprAgenticScopeStore.java | 159 ++++++++++++++++++ .../scope/DaprAgenticScopeStoreTest.java | 115 +++++++++++++ 5 files changed, 354 insertions(+) create mode 100644 quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/scope/AgenticScopeStoreInitializer.java create mode 100644 quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/scope/DaprAgenticScopeStore.java create mode 100644 quarkus/runtime/src/test/java/io/dapr/quarkus/langchain4j/scope/DaprAgenticScopeStoreTest.java diff --git a/quarkus/README.md b/quarkus/README.md index 9a6c8ccac2..9124fd8384 100644 --- a/quarkus/README.md +++ b/quarkus/README.md @@ -147,6 +147,23 @@ In the Dapr dashboard, completed agents show cached results. The crashed agent i | `ToolRegistry` | CDI bean that discovers @Tool methods at startup for recovery | | `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 - **Nested composites**: `@ParallelAgent` inside `@SequenceAgent` is unstable (input type mismatch) diff --git a/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java b/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java index 6a74fab991..de7d746eee 100644 --- a/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java +++ b/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java @@ -376,6 +376,9 @@ void registerAdditionalBeans(BuildProducer additionalBe // Tool registry for crash recovery — scans @Tool CDI beans at startup. additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf( "io.dapr.quarkus.langchain4j.agent.recovery.ToolRegistry")); + // Registers the Dapr-backed AgenticScope store (checkpointing) when enabled. + additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf( + "io.dapr.quarkus.langchain4j.scope.AgenticScopeStoreInitializer")); } /** diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/scope/AgenticScopeStoreInitializer.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/scope/AgenticScopeStoreInitializer.java new file mode 100644 index 0000000000..70c7dccaba --- /dev/null +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/scope/AgenticScopeStoreInitializer.java @@ -0,0 +1,60 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.langchain4j.scope; + +import dev.langchain4j.agentic.scope.AgenticScopePersister; +import io.dapr.client.DaprClient; +import io.quarkus.runtime.StartupEvent; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; + +/** + * Registers {@link DaprAgenticScopeStore} as LangChain4j's agentic scope persistence + * provider at startup, so agentic workflow state (shared state, conversation context) + * is checkpointed to a Dapr state store on every update. + * + *

Opt-in via configuration: + *

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

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

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

Note: index updates are synchronized within this instance; concurrent writers in other + * replicas may race on the index (the scope entries themselves are never lost — only the + * listing may briefly miss entries). Keys reconstructed from the index carry the + * {@code memoryId} as a string. + */ +public class DaprAgenticScopeStore implements AgenticScopeStore { + + private static final Logger LOG = Logger.getLogger(DaprAgenticScopeStore.class); + + private static final String KEY_PREFIX = "agenticscope||"; + private static final String INDEX_KEY = KEY_PREFIX + "_index"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final DaprClient daprClient; + private final String stateStoreName; + + /** + * Creates a new DaprAgenticScopeStore. + * + * @param daprClient the Dapr client + * @param stateStoreName the state store component name + */ + public DaprAgenticScopeStore(DaprClient daprClient, String stateStoreName) { + this.daprClient = daprClient; + this.stateStoreName = stateStoreName; + } + + @Override + public boolean save(AgenticScopeKey key, DefaultAgenticScope agenticScope) { + try { + String json = AgenticScopeSerializer.toJson(agenticScope); + daprClient.saveState(stateStoreName, stateKey(key), json).block(); + updateIndex(index -> index.add(indexEntry(key))); + LOG.debugf("Saved agentic scope %s (%d bytes)", stateKey(key), json.length()); + return true; + } catch (Exception e) { + LOG.errorf("Failed to save agentic scope %s: %s", stateKey(key), e.getMessage()); + return false; + } + } + + @Override + public Optional load(AgenticScopeKey key) { + try { + State state = daprClient + .getState(stateStoreName, stateKey(key), String.class).block(); + if (state == null || state.getValue() == null || state.getValue().isEmpty()) { + return Optional.empty(); + } + return Optional.of(AgenticScopeSerializer.fromJson(state.getValue())); + } catch (Exception e) { + LOG.errorf("Failed to load agentic scope %s: %s", stateKey(key), e.getMessage()); + return Optional.empty(); + } + } + + @Override + public boolean delete(AgenticScopeKey key) { + try { + boolean existed = load(key).isPresent(); + daprClient.deleteState(stateStoreName, stateKey(key)).block(); + updateIndex(index -> index.remove(indexEntry(key))); + return existed; + } catch (Exception e) { + LOG.errorf("Failed to delete agentic scope %s: %s", stateKey(key), e.getMessage()); + return false; + } + } + + @Override + public Set getAllKeys() { + Set keys = new HashSet<>(); + for (String entry : readIndex()) { + int separator = entry.indexOf("||"); + if (separator > 0) { + keys.add(new AgenticScopeKey( + entry.substring(0, separator), entry.substring(separator + 2))); + } + } + return keys; + } + + private static String stateKey(AgenticScopeKey key) { + return KEY_PREFIX + indexEntry(key); + } + + private static String indexEntry(AgenticScopeKey key) { + return key.agentId() + "||" + key.memoryId(); + } + + private Set readIndex() { + try { + State state = daprClient + .getState(stateStoreName, INDEX_KEY, String.class).block(); + if (state == null || state.getValue() == null || state.getValue().isEmpty()) { + return new LinkedHashSet<>(); + } + return MAPPER.readValue(state.getValue(), new TypeReference>() { + }); + } catch (Exception e) { + LOG.warnf("Failed to read agentic scope index: %s", e.getMessage()); + return new LinkedHashSet<>(); + } + } + + private synchronized void updateIndex(java.util.function.Consumer> mutation) { + try { + Set index = readIndex(); + mutation.accept(index); + daprClient.saveState(stateStoreName, INDEX_KEY, + MAPPER.writeValueAsString(index)).block(); + } catch (Exception e) { + LOG.warnf("Failed to update agentic scope index: %s", e.getMessage()); + } + } +} diff --git a/quarkus/runtime/src/test/java/io/dapr/quarkus/langchain4j/scope/DaprAgenticScopeStoreTest.java b/quarkus/runtime/src/test/java/io/dapr/quarkus/langchain4j/scope/DaprAgenticScopeStoreTest.java new file mode 100644 index 0000000000..f0e498b869 --- /dev/null +++ b/quarkus/runtime/src/test/java/io/dapr/quarkus/langchain4j/scope/DaprAgenticScopeStoreTest.java @@ -0,0 +1,115 @@ +package io.dapr.quarkus.langchain4j.scope; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import dev.langchain4j.agentic.scope.AgenticScopeKey; +import dev.langchain4j.agentic.scope.AgenticScopeSerializer; +import dev.langchain4j.agentic.scope.DefaultAgenticScope; +import io.dapr.client.DaprClient; +import io.dapr.client.domain.State; +import reactor.core.publisher.Mono; + +class DaprAgenticScopeStoreTest { + + private static final String STATE_STORE_NAME = "statestore"; + private static final String SCOPE_KEY = "agenticscope||agent-1||memory-1"; + private static final String INDEX_KEY = "agenticscope||_index"; + + private DaprClient daprClient; + private DaprAgenticScopeStore store; + + @BeforeEach + void setUp() { + daprClient = mock(DaprClient.class); + store = new DaprAgenticScopeStore(daprClient, STATE_STORE_NAME); + // Default: empty state for any key + when(daprClient.getState(eq(STATE_STORE_NAME), anyString(), eq(String.class))) + .thenReturn(Mono.just(new State<>("any", (String) null, (String) null))); + when(daprClient.saveState(eq(STATE_STORE_NAME), anyString(), anyString())) + .thenReturn(Mono.empty()); + when(daprClient.deleteState(eq(STATE_STORE_NAME), anyString())) + .thenReturn(Mono.empty()); + } + + private static DefaultAgenticScope scope(String memoryId) { + return AgenticScopeSerializer.fromJson( + "{\"memoryId\":\"" + memoryId + "\",\"kind\":\"PERSISTENT\"}"); + } + + @Test + void saveShouldSerializeScopeAndUpdateIndex() { + boolean result = store.save(new AgenticScopeKey("agent-1", "memory-1"), scope("memory-1")); + + assertThat(result).isTrue(); + ArgumentCaptor valueCaptor = ArgumentCaptor.forClass(String.class); + verify(daprClient).saveState(eq(STATE_STORE_NAME), eq(SCOPE_KEY), valueCaptor.capture()); + assertThat(valueCaptor.getValue()).contains("memory-1"); + // Index updated with the new entry + verify(daprClient).saveState(eq(STATE_STORE_NAME), eq(INDEX_KEY), + eq("[\"agent-1||memory-1\"]")); + } + + @Test + void loadShouldReturnEmptyWhenKeyDoesNotExist() { + Optional loaded = store.load(new AgenticScopeKey("agent-1", "memory-1")); + + assertThat(loaded).isEmpty(); + } + + @Test + void loadShouldRoundTripSavedScope() { + String json = AgenticScopeSerializer.toJson(scope("memory-1")); + when(daprClient.getState(eq(STATE_STORE_NAME), eq(SCOPE_KEY), eq(String.class))) + .thenReturn(Mono.just(new State<>(SCOPE_KEY, json, (String) null))); + + Optional loaded = store.load(new AgenticScopeKey("agent-1", "memory-1")); + + assertThat(loaded).isPresent(); + assertThat(loaded.get().memoryId()).isEqualTo("memory-1"); + } + + @Test + void deleteShouldRemoveStateAndIndexEntry() { + String json = AgenticScopeSerializer.toJson(scope("memory-1")); + when(daprClient.getState(eq(STATE_STORE_NAME), eq(SCOPE_KEY), eq(String.class))) + .thenReturn(Mono.just(new State<>(SCOPE_KEY, json, (String) null))); + when(daprClient.getState(eq(STATE_STORE_NAME), eq(INDEX_KEY), eq(String.class))) + .thenReturn(Mono.just(new State<>(INDEX_KEY, "[\"agent-1||memory-1\"]", (String) null))); + + boolean result = store.delete(new AgenticScopeKey("agent-1", "memory-1")); + + assertThat(result).isTrue(); + verify(daprClient).deleteState(STATE_STORE_NAME, SCOPE_KEY); + verify(daprClient).saveState(eq(STATE_STORE_NAME), eq(INDEX_KEY), eq("[]")); + } + + @Test + void getAllKeysShouldParseIndexEntries() { + when(daprClient.getState(eq(STATE_STORE_NAME), eq(INDEX_KEY), eq(String.class))) + .thenReturn(Mono.just(new State<>(INDEX_KEY, + "[\"agent-1||memory-1\",\"agent-2||memory-2\"]", (String) null))); + + Set keys = store.getAllKeys(); + + assertThat(keys).containsExactlyInAnyOrder( + new AgenticScopeKey("agent-1", "memory-1"), + new AgenticScopeKey("agent-2", "memory-2")); + } + + @Test + void getAllKeysShouldReturnEmptySetWhenIndexMissing() { + assertThat(store.getAllKeys()).isEmpty(); + } +} From 6dd5afaeaa596cf3e4a18a805ecd7bae9333173d Mon Sep 17 00:00:00 2001 From: Javier Aliaga Date: Wed, 10 Jun 2026 13:55:51 +0200 Subject: [PATCH 12/21] fix(quarkus): narrow extractPrompt catch to satisfy SpotBugs REC_CATCH_EXCEPTION The try block only throws ReflectiveOperationException and runtime exceptions; catching Exception fails spotbugs:check, which gates the root Build/Javadocs CI jobs. Signed-off-by: Javier Aliaga --- .../dapr/quarkus/langchain4j/agent/DaprChatModelDecorator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelDecorator.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelDecorator.java index c60ad0217e..0a6aaf33bd 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelDecorator.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelDecorator.java @@ -239,7 +239,7 @@ private String extractPrompt(ChatRequest request) { } sb.append("]"); return sb.toString(); - } catch (Exception ex) { + } catch (ReflectiveOperationException | RuntimeException ex) { return String.valueOf(request); } } From 70e0930f33b66c0f69c343ff3c1ac07b2ddf3087 Mon Sep 17 00:00:00 2001 From: Javier Aliaga Date: Wed, 10 Jun 2026 20:02:32 +0200 Subject: [PATCH 13/21] docs(quarkus): fix javadoc errors gating the Validate Javadocs CI job - Replace

with

(doclint: empty

tag + heading out of sequence after implicit H1) - Fix stale {@link} references: DaprLlmCallInterceptor (removed class, now DaprChatModelDecorator), unqualified AgentRunWorkflow from the agent package, and quarkiverse-internal WorkflowItemBuildItem These only surfaced once SpotBugs stopped aborting the aggregate run. Signed-off-by: Javier Aliaga --- .../deployment/DaprAgenticProcessor.java | 6 +++--- .../agent/AgentRunLifecycleManager.java | 20 ++++++++++++------- .../agent/DaprAgentMethodInterceptor.java | 3 ++- .../agent/DaprChatModelDecorator.java | 6 +++--- .../agent/DaprToolCallInterceptor.java | 6 +++--- .../agent/activities/LlmCallActivity.java | 4 ++-- .../agent/activities/LlmCallInput.java | 2 +- .../agent/activities/LlmCallOutput.java | 2 +- .../agent/activities/ToolCallActivity.java | 2 +- .../agent/workflow/AgentRunWorkflow.java | 4 ++-- 10 files changed, 31 insertions(+), 24 deletions(-) diff --git a/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java b/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java index de7d746eee..d3d1230ea1 100644 --- a/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java +++ b/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java @@ -70,7 +70,7 @@ *

  • Produce an {@link IndexDependencyBuildItem} so our runtime JAR is indexed into * the {@link CombinedIndexBuildItem} (and visible to Arc for CDI bean discovery).
  • *
  • Consume the {@link CombinedIndexBuildItem}, look up our Workflow and WorkflowActivity - * classes, and produce {@link WorkflowItemBuildItem} instances that the existing + * classes, and produce {@code WorkflowItemBuildItem} instances that the existing * {@code DaprWorkflowProcessor} build steps consume to register with the Dapr * workflow runtime.
  • *
  • Produce {@link AdditionalBeanBuildItem} instances so Arc explicitly discovers @@ -385,7 +385,7 @@ void registerAdditionalBeans(BuildProducer additionalBe * Generates a CDI {@code @Decorator} for every interface that declares at least one * {@code @Agent}-annotated method. * - *

    Why a generated decorator?

    + *

    Why a generated decorator?

    * quarkus-langchain4j registers {@code @Agent} AiService beans as synthetic beans * (via {@code SyntheticBeanBuildItem}) -- CDI interceptors applied via * {@code AnnotationsTransformer} are silently ignored on synthetic beans. CDI @@ -395,7 +395,7 @@ void registerAdditionalBeans(BuildProducer additionalBe * {@link io.dapr.quarkus.langchain4j.agent.DaprChatModelDecorator} * to wrap the synthetic {@code ChatModel} bean. * - *

    What the generated decorator does

    + *

    What the generated decorator does

    * For each interface {@code I} with at least one {@code @Agent} method, Gizmo emits a class * equivalent to: *
    {@code
    diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/AgentRunLifecycleManager.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/AgentRunLifecycleManager.java
    index b546d2c781..264cc7c86a 100644
    --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/AgentRunLifecycleManager.java
    +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/AgentRunLifecycleManager.java
    @@ -27,20 +27,23 @@
     import java.util.UUID;
     /**
      * Request-scoped CDI bean that manages the lifecycle of a lazily-started
    - * {@link AgentRunWorkflow} for standalone {@code @Agent} invocations.
    + * {@link io.dapr.quarkus.langchain4j.agent.workflow.AgentRunWorkflow
    + * AgentRunWorkflow} for standalone {@code @Agent} invocations.
      *
    - * 

    Why this exists

    + *

    Why this exists

    * {@code @Agent} interfaces in quarkus-langchain4j are registered as synthetic beans * (via {@code SyntheticBeanBuildItem}) without interception enabled. This means CDI interceptors * such as {@code DaprAgentMethodInterceptor} cannot fire on {@code @Agent} method calls. * *

    Instead, {@link DaprToolCallInterceptor} calls {@link #getOrActivate()} on the first * {@code @Tool} method call it intercepts within a request that has no active Dapr agent context. - * This lazily starts the {@link AgentRunWorkflow} and sets {@link DaprAgentContextHolder} so + * This lazily starts the {@link io.dapr.quarkus.langchain4j.agent.workflow.AgentRunWorkflow + * AgentRunWorkflow} and sets {@link DaprAgentContextHolder} so * that all subsequent tool calls within the same request are also routed through Dapr. * *

    When the CDI request scope is destroyed (i.e., after the HTTP response is sent), - * {@link #cleanup()} sends the {@code "done"} event that terminates the {@link AgentRunWorkflow}. + * {@link #cleanup()} sends the {@code "done"} event that terminates the + * {@link io.dapr.quarkus.langchain4j.agent.workflow.AgentRunWorkflow AgentRunWorkflow}. */ @RequestScoped @@ -55,7 +58,8 @@ public class AgentRunLifecycleManager { /** * Returns the active agent run ID for this request, lazily starting an - * {@link AgentRunWorkflow} if one has not been created yet. + * {@link io.dapr.quarkus.langchain4j.agent.workflow.AgentRunWorkflow + * AgentRunWorkflow} if one has not been created yet. * *

    This overload accepts the agent name and prompt metadata extracted from the * {@code @Agent}, {@code @UserMessage}, and {@code @SystemMessage} annotations (CDI bean @@ -87,7 +91,8 @@ public String getOrActivate(String agentName, String userMessage, String systemM /** * Returns the active agent run ID for this request, lazily starting an - * {@link AgentRunWorkflow} if one has not been created yet. + * {@link io.dapr.quarkus.langchain4j.agent.workflow.AgentRunWorkflow + * AgentRunWorkflow} if one has not been created yet. * *

    Uses {@code "standalone"} as the agent name and {@code null} for prompt metadata. * Prefer {@link #getOrActivate(String, String, String)} when agent metadata is available. @@ -107,7 +112,8 @@ public String getOrActivate() { } /** - * Signals the active {@link AgentRunWorkflow} that the {@code @Agent} method has finished, + * Signals the active {@link io.dapr.quarkus.langchain4j.agent.workflow.AgentRunWorkflow + * AgentRunWorkflow} that the {@code @Agent} method has finished, * then unregisters the run and clears the context holder. * *

    Called directly by the generated CDI decorator when the {@code @Agent} method diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprAgentMethodInterceptor.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprAgentMethodInterceptor.java index fd29bcdcb6..637d8232ec 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprAgentMethodInterceptor.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprAgentMethodInterceptor.java @@ -32,7 +32,8 @@ import java.util.UUID; /** - * CDI interceptor that starts a Dapr {@link AgentRunWorkflow} for any standalone + * CDI interceptor that starts a Dapr + * {@link io.dapr.quarkus.langchain4j.agent.workflow.AgentRunWorkflow AgentRunWorkflow} for any standalone * {@code @Agent}-annotated method invocation. * *

    Note: In practice this interceptor only fires when the {@code @Agent} diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelDecorator.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelDecorator.java index 0a6aaf33bd..acb68ed093 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelDecorator.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprChatModelDecorator.java @@ -38,7 +38,7 @@ * CDI Decorator that routes {@code ChatModel.chat(ChatRequest)} calls through a Dapr * Workflow Activity when executing inside an active agent run. * - *

    Why a decorator instead of a CDI interceptor

    + *

    Why a decorator instead of a CDI interceptor

    * quarkus-langchain4j registers {@code ChatModel} as a synthetic bean * ({@code SyntheticBeanBuildItem}). Arc does not apply CDI interceptors to synthetic * beans based on {@code AnnotationsTransformer} modifications to the interface — the @@ -46,7 +46,7 @@ * however, work at the bean type level and are applied by Arc to any bean (including * synthetic beans) whose types include the delegate type. * - *

    Execution flow

    + *

    Execution flow

    *
      *
    1. The LangChain4j AiService calls {@code chatModel.chat(request)} which routes * through this decorator.
    2. @@ -65,7 +65,7 @@ * unblocking the agent thread. *
    * - *

    Lazy activation

    + *

    Lazy activation

    * When an {@code @Agent} method is called standalone (no orchestration workflow), * the first LLM call will find no active {@code agentRunId} in {@link DaprAgentContextHolder}. * This decorator calls {@link AgentRunLifecycleManager#getOrActivate()} to lazily start diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprToolCallInterceptor.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprToolCallInterceptor.java index e8c2c646fd..1503d8a157 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprToolCallInterceptor.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/DaprToolCallInterceptor.java @@ -32,13 +32,13 @@ * CDI interceptor that routes {@code @Tool}-annotated method calls through a Dapr Workflow * Activity when executing inside a Dapr-backed agent run. * - *

    Execution flow (orchestration-driven)

    + *

    Execution flow (orchestration-driven)

    * When an agent is run via an orchestration workflow ({@code @SequenceAgent} etc.), * {@code AgentExecutionActivity} sets {@link DaprAgentContextHolder} before the agent starts. * Tool calls find a non-null {@code agentRunId} and are routed through * {@link io.dapr.quarkus.langchain4j.agent.activities.ToolCallActivity}. * - *

    Execution flow (standalone {@code @Agent})

    + *

    Execution flow (standalone {@code @Agent})

    * When an {@code @Agent}-annotated method is called directly (without an orchestrator), * {@link DaprAgentContextHolder} is null on the first tool call. In this case the interceptor * calls {@link AgentRunLifecycleManager#getOrActivate()} to lazily start an @@ -46,7 +46,7 @@ * The workflow is terminated by {@link AgentRunLifecycleManager}'s {@code @PreDestroy} when the * CDI request scope ends. * - *

    Deadlock prevention

    + *

    Deadlock prevention

    * {@code ToolCallActivity} calls the {@code @Tool} method via reflection on the CDI proxy. This * would cause the interceptor to fire again. The {@link #IS_ACTIVITY_CALL} {@code ThreadLocal} * prevents recursion: when set on the activity thread, the interceptor calls {@code ctx.proceed()} diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallActivity.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallActivity.java index b7ba0bcf60..9b0f012da8 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallActivity.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallActivity.java @@ -28,13 +28,13 @@ * Dapr Workflow Activity that executes a single {@code ChatModel.chat(ChatRequest)} call on * behalf of a running {@link io.dapr.quarkus.langchain4j.agent.workflow.AgentRunWorkflow}. * - *

    How it works

    + *

    How it works

    *
      *
    1. Receives {@link LlmCallInput} with the {@code agentRunId}, {@code llmCallId}, * {@code methodName}, and the serialized {@code prompt} (messages sent to the LLM).
    2. *
    3. Looks up the {@link AgentRunContext} from {@link DaprAgentRunRegistry}.
    4. *
    5. Retrieves the {@link AgentRunContext.PendingCall} registered by - * {@link io.dapr.quarkus.langchain4j.agent.DaprLlmCallInterceptor}.
    6. + * {@link io.dapr.quarkus.langchain4j.agent.DaprChatModelDecorator}. *
    7. Sets {@link DaprToolCallInterceptor#IS_ACTIVITY_CALL} on this thread so that * {@code DaprChatModelDecorator} passes through to {@code delegate.chat()} when * re-invoked via reflection on the stored decorator instance.
    8. diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallInput.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallInput.java index a6b1f2c282..5e56589467 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallInput.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallInput.java @@ -22,7 +22,7 @@ * @param methodName name of the {@code ChatModel} method being called (e.g., {@code "chat"}); * stored in the Dapr activity input for observability in the workflow history * @param prompt string representation of the {@code ChatRequest} messages sent to the LLM; - * extracted by {@link io.dapr.quarkus.langchain4j.agent.DaprLlmCallInterceptor} + * extracted by {@link io.dapr.quarkus.langchain4j.agent.DaprChatModelDecorator} * and stored in the Dapr activity input so the full prompt is visible in the * workflow history without needing to inspect in-process state */ diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallOutput.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallOutput.java index fc98820778..3307dac4a4 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallOutput.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/LlmCallOutput.java @@ -21,7 +21,7 @@ * @param methodName name of the {@code ChatModel} method that was invoked (e.g., {@code "chat"}) * @param prompt serialized {@code ChatRequest} messages that were sent to the model; * extracted from the {@code ChatRequest} argument by - * {@link io.dapr.quarkus.langchain4j.agent.DaprLlmCallInterceptor} + * {@link io.dapr.quarkus.langchain4j.agent.DaprChatModelDecorator} * @param response AI response text extracted from {@code ChatResponse.aiMessage().text()}; * this is the exact text the model returned to the agent */ diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/ToolCallActivity.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/ToolCallActivity.java index a0b8c854c2..9847ce010a 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/ToolCallActivity.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/activities/ToolCallActivity.java @@ -27,7 +27,7 @@ * Dapr Workflow Activity that executes a single {@code @Tool}-annotated method call on * behalf of a running {@link io.dapr.quarkus.langchain4j.agent.workflow.AgentRunWorkflow}. * - *

      How it works

      + *

      How it works

      *
        *
      1. Receives {@link ToolCallInput} with the {@code agentRunId} and {@code toolCallId}.
      2. *
      3. Looks up the {@link AgentRunContext} from {@link DaprAgentRunRegistry}.
      4. diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/workflow/AgentRunWorkflow.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/workflow/AgentRunWorkflow.java index c63cc7a143..ea3d32fa7c 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/workflow/AgentRunWorkflow.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/workflow/AgentRunWorkflow.java @@ -32,7 +32,7 @@ * Dapr Workflow representing the execution of a single {@code @Agent}-annotated method, * including all tool and LLM calls the agent makes during its ReAct loop. * - *

        Lifecycle (normal path)

        + *

        Lifecycle (normal path)

        *
          *
        1. Started by {@link io.dapr.quarkus.langchain4j.workflow.orchestration.activities.AgentExecutionActivity} * (orchestration path) or lazily by {@link io.dapr.quarkus.langchain4j.agent.AgentRunLifecycleManager} @@ -47,7 +47,7 @@ *
        2. Terminates when a {@code "done"} event is received.
        3. *
        * - *

        Crash recovery

        + *

        Crash recovery

        * After a crash, the Dapr runtime replays this workflow. Cached events and activities return * instantly. When replay reaches the activity that was in-progress during the crash, the * activity is re-dispatched but fails because the in-memory {@code AgentRunContext} is gone. From dbf26f9653f6244a246a6839fec7be84c14d2ca0 Mon Sep 17 00:00:00 2001 From: Javier Aliaga Date: Thu, 11 Jun 2026 10:48:08 +0200 Subject: [PATCH 14/21] chore(quarkus): use released java-sdk 1.18.0 and final Dapr runtime images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dapr.sdk.version: 1.18.0-rc-3 -> 1.18.0 (released on Maven Central) - Dev services: daprio/daprd:1.18.0 — the canonical image pinned by testcontainers-dapr 1.18.0 (daprd/placement/scheduler all final) Signed-off-by: Javier Aliaga --- quarkus/examples/src/test/resources/application.properties | 6 +++--- quarkus/pom.xml | 2 +- .../src/test/resources/application.properties | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/quarkus/examples/src/test/resources/application.properties b/quarkus/examples/src/test/resources/application.properties index fefaef11a1..00e0596475 100644 --- a/quarkus/examples/src/test/resources/application.properties +++ b/quarkus/examples/src/test/resources/application.properties @@ -2,9 +2,9 @@ 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-rc-3 (canonical -# daprd/placement/scheduler images are rc.4; Testcontainers rejects other tags) -quarkus.dapr.devservices.daprd-image=daprio/daprd:1.18.0-rc.4 +# 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 diff --git a/quarkus/pom.xml b/quarkus/pom.xml index 29fcf61c0c..484f06b7e6 100644 --- a/quarkus/pom.xml +++ b/quarkus/pom.xml @@ -33,7 +33,7 @@ 2.5.0 1.7.1 - 1.18.0-rc-3 + 1.18.0 diff --git a/quarkus/quarkus-agentic-dapr-agents-registry/src/test/resources/application.properties b/quarkus/quarkus-agentic-dapr-agents-registry/src/test/resources/application.properties index 41351d4413..7a3cdf6d62 100644 --- a/quarkus/quarkus-agentic-dapr-agents-registry/src/test/resources/application.properties +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/test/resources/application.properties @@ -1,9 +1,9 @@ # Dapr Dev Services with PostgreSQL state store (dashboard enabled = uses PostgreSQL) quarkus.dapr.devservices.enabled=true quarkus.dapr.devservices.dashboard.enabled=true -# Match the Dapr runtime pinned by testcontainers-dapr 1.18.0-rc-3 (canonical -# daprd/placement/scheduler images are rc.4; Testcontainers rejects other tags) -quarkus.dapr.devservices.daprd-image=daprio/daprd:1.18.0-rc.4 +# 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 From cc54b375a0f84f2aff786a2ac29206d8a4a326e9 Mon Sep 17 00:00:00 2001 From: Javier Aliaga Date: Thu, 11 Jun 2026 12:33:58 +0200 Subject: [PATCH 15/21] fix(quarkus): make parallel orchestration reliable and durable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coordinated fixes to the planner bridge: 1. Identity-based completion correlation: nextAction() now correlates each completion via PlanningContext.previousAgentInvocation().agentId() instead of FIFO-polling deques. Parallel completion order is arbitrary, so FIFO routed done/agent-complete events to the wrong agent run and hung the orchestration. 2. Idempotent agent submission: agent-call activities are delivered at-least-once; executeAgent() now dedupes by agentRunId so a redelivery cannot run the agent twice, and AgentExecutionActivity no longer replaces an in-flight AgentRunContext (which orphaned pending calls). 3. Per-thread Dapr context via decorator binding: the planner cannot set DaprAgentContextHolder on LangChain4j's parallel executor threads, so parallel agents' LLM/tool calls silently bypassed Dapr (no durability, no audit trail). AgentExecutionActivity now binds agentName→agentRunId and the generated CDI decorator claims the binding on the agent's own thread, routing every call through its AgentRunWorkflow. StoryResourceTest and ParallelResourceTest are un-quarantined and green — including the nested @SequenceAgent inside @ParallelAgent scenario. Remaining known flake (documented in README): daprd 1.18.0 can lose a child workflow completion (child completes app-side but stays RUNNING in daprd; its new-event reminder fires on an empty inbox), hanging ~1 in 6 multi-request runs. The examples suite retries once to absorb it; needs an upstream dapr/dapr issue. Signed-off-by: Javier Aliaga --- quarkus/README.md | 7 +- .../deployment/DaprAgenticProcessor.java | 59 +++++++++++- quarkus/examples/pom.xml | 6 ++ .../examples/ParallelResourceTest.java | 2 - .../quarkus/examples/StoryResourceTest.java | 2 - .../agent/AgentRunBindingRegistry.java | 64 +++++++++++++ .../workflow/DaprWorkflowPlanner.java | 96 +++++++++++-------- .../activities/AgentExecutionActivity.java | 12 ++- .../workflow/DaprWorkflowPlannerTest.java | 59 +++++++++++- 9 files changed, 258 insertions(+), 49 deletions(-) create mode 100644 quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/AgentRunBindingRegistry.java diff --git a/quarkus/README.md b/quarkus/README.md index 9124fd8384..4290f5d59f 100644 --- a/quarkus/README.md +++ b/quarkus/README.md @@ -166,6 +166,11 @@ dapr.agentic.scope-store.name=kvstore ## Known Limitations -- **Nested composites**: `@ParallelAgent` inside `@SequenceAgent` is unstable (input type mismatch) - **Recovery granularity**: Agent-level only — individual LLM/tool calls within an agent are re-executed (not skipped) +- **Same-named agents in concurrent orchestrations**: completion routing is exact, but the + per-thread context binding is claimed FIFO by agent name, so observability may + cross-attribute runs between concurrent requests using the same agent name +- **daprd 1.18.0 workflow race**: a child workflow completion can occasionally be lost by + the runtime (child completes app-side but stays RUNNING in daprd, its event reminder + fires on an empty inbox), hanging that request. Tests retry once to absorb it. - **Small models**: llama3.2 (3B) sometimes malforms tool call arguments; llama3.1:8b+ recommended diff --git a/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java b/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java index d3d1230ea1..ee7f9398c5 100644 --- a/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java +++ b/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java @@ -13,7 +13,9 @@ package io.dapr.quarkus.langchain4j.deployment; +import io.dapr.quarkus.langchain4j.agent.AgentRunBindingRegistry; import io.dapr.quarkus.langchain4j.agent.AgentRunLifecycleManager; +import io.dapr.quarkus.langchain4j.agent.DaprAgentContextHolder; import io.dapr.quarkus.langchain4j.agent.DaprAgentMetadataHolder; import io.dapr.quarkus.langchain4j.workflow.DaprWorkflowRuntimeRecorder; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; @@ -28,6 +30,8 @@ import io.quarkus.deployment.builditem.CombinedIndexBuildItem; import io.quarkus.deployment.builditem.FeatureBuildItem; import io.quarkus.deployment.builditem.IndexDependencyBuildItem; +import io.quarkus.gizmo.BranchResult; +import io.quarkus.gizmo.BytecodeCreator; import io.quarkus.gizmo.CatchBlockCreator; import io.quarkus.gizmo.ClassCreator; import io.quarkus.gizmo.ClassOutput; @@ -538,8 +542,6 @@ private void generateDecoratedAgentMethod(ClassCreator cc, MethodInfo method, FieldDescriptor delegateDesc, FieldDescriptor lcmDesc) { String agentName = extractAgentName(method); - String userMessage = extractAnnotationText(method, USER_MESSAGE_ANNOTATION); - String systemMessage = extractAnnotationText(method, SYSTEM_MESSAGE_ANNOTATION); final boolean isVoid = method.returnType().kind() == Type.Kind.VOID; MethodCreator mc = cc.getMethodCreator(MethodDescriptor.of(method)); @@ -548,6 +550,59 @@ private void generateDecoratedAgentMethod(ClassCreator cc, MethodInfo method, mc.addException(exType.name().toString()); } + // Orchestration path: if AgentExecutionActivity bound an agentRunId for this agent, + // claim it and set the Dapr context on THIS thread — LangChain4j's parallel executor + // threads are invisible to the planner, so this is the only place the context can be + // set for parallel agents. The planner's nextAction() handles done-signaling, so the + // bound path delegates directly without the standalone lifecycle (getOrActivate / + // triggerDone). + // + // String claimed = AgentRunBindingRegistry.claim(agentName); + // if (claimed != null) { + // DaprAgentContextHolder.set(claimed); + // try { [result =] delegate.method(params); } + // finally { DaprAgentContextHolder.clear(); } + // return [result]; + // } + ResultHandle claimedRunId = mc.invokeStaticMethod( + MethodDescriptor.ofMethod(AgentRunBindingRegistry.class, "claim", + String.class, String.class), + mc.load(agentName)); + BranchResult claimBranch = mc.ifNull(claimedRunId); + BytecodeCreator bound = claimBranch.falseBranch(); + { + bound.invokeStaticMethod( + MethodDescriptor.ofMethod(DaprAgentContextHolder.class, "set", + void.class, String.class), + claimedRunId); + TryBlock boundTry = bound.tryBlock(); + ResultHandle boundDelegate = boundTry.readInstanceField(delegateDesc, boundTry.getThis()); + ResultHandle[] boundParams = new ResultHandle[method.parametersCount()]; + for (int i = 0; i < boundParams.length; i++) { + boundParams[i] = boundTry.getMethodParam(i); + } + if (isVoid) { + boundTry.invokeInterfaceMethod(MethodDescriptor.of(method), boundDelegate, boundParams); + boundTry.invokeStaticMethod( + MethodDescriptor.ofMethod(DaprAgentContextHolder.class, "clear", void.class)); + boundTry.returnVoid(); + } else { + ResultHandle boundResult = boundTry.invokeInterfaceMethod( + MethodDescriptor.of(method), boundDelegate, boundParams); + boundTry.invokeStaticMethod( + MethodDescriptor.ofMethod(DaprAgentContextHolder.class, "clear", void.class)); + boundTry.returnValue(boundResult); + } + CatchBlockCreator boundCatch = boundTry.addCatch(Throwable.class); + boundCatch.invokeStaticMethod( + MethodDescriptor.ofMethod(DaprAgentContextHolder.class, "clear", void.class)); + boundCatch.throwException(boundCatch.getCaughtException()); + } + // claimed == null → standalone path below. + + String userMessage = extractAnnotationText(method, USER_MESSAGE_ANNOTATION); + String systemMessage = extractAnnotationText(method, SYSTEM_MESSAGE_ANNOTATION); + // Store @Agent metadata on the current thread so that DaprChatModelDecorator can // retrieve the real agent name and messages if the activation below fails and the // decorator falls through to direct delegation (lazy-activation path). diff --git a/quarkus/examples/pom.xml b/quarkus/examples/pom.xml index 7db01a61d1..ecc87f4605 100644 --- a/quarkus/examples/pom.xml +++ b/quarkus/examples/pom.xml @@ -88,6 +88,12 @@ org.jboss.logmanager.LogManager + + 1 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 index 1b82a596ff..09a41d9e78 100644 --- a/quarkus/examples/src/test/java/io/dapr/quarkus/examples/ParallelResourceTest.java +++ b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/ParallelResourceTest.java @@ -4,7 +4,6 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.notNullValue; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -19,7 +18,6 @@ *

        * Requires Docker for Dapr dev services. Uses {@link MockChatModel} instead of a real LLM. */ -@Disabled("Known race in the in-memory planner bridge: duplicate agent-call activity dispatch and FIFO completion correlation can hang back-to-back/parallel requests. Tracked for follow-up.") @QuarkusTest @ExtendWith(DockerAvailableCondition.class) class ParallelResourceTest { 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 index 114fab9e62..5de3a60bd4 100644 --- a/quarkus/examples/src/test/java/io/dapr/quarkus/examples/StoryResourceTest.java +++ b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/StoryResourceTest.java @@ -3,7 +3,6 @@ import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.notNullValue; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -16,7 +15,6 @@ * PostgreSQL state store, and dashboard containers via Testcontainers). * Uses {@link MockChatModel} instead of a real LLM. */ -@Disabled("Known race in the in-memory planner bridge: duplicate agent-call activity dispatch and FIFO completion correlation can hang back-to-back/parallel requests. Tracked for follow-up.") @QuarkusTest @ExtendWith(DockerAvailableCondition.class) class StoryResourceTest { diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/AgentRunBindingRegistry.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/AgentRunBindingRegistry.java new file mode 100644 index 0000000000..3aef3de698 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/AgentRunBindingRegistry.java @@ -0,0 +1,64 @@ +/* + * 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; + +import java.util.Deque; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedDeque; + +/** + * Binds agent names to their pending {@code agentRunId}s so the generated CDI decorator + * can claim the right Dapr context on the agent's own executing thread. + * + *

        In parallel orchestration, LangChain4j runs each agent on its own executor thread — + * threads the planner does not own, so it cannot set {@link DaprAgentContextHolder} for + * them. Instead, {@code AgentExecutionActivity} binds {@code agentName → agentRunId} at + * submit time, and the generated decorator (which executes on the agent's thread and + * knows its own agent name) claims the binding and sets the context before delegating. + * This is what routes parallel agents' LLM and tool calls through their own + * {@link io.dapr.quarkus.langchain4j.agent.workflow.AgentRunWorkflow}. + * + *

        Bindings are claimed FIFO per agent name. If the same agent name is in flight for + * multiple concurrent orchestrations, claims may cross-attribute runs between them — + * run IDs are unique so routing still completes, but observability may interleave. + */ +public final class AgentRunBindingRegistry { + + private static final Map> BINDINGS = new ConcurrentHashMap<>(); + + private AgentRunBindingRegistry() { + } + + /** + * Binds a pending agent run to its agent name. + * + * @param agentName the agent name (from {@code @Agent(name)} / {@code AgentInstance.name()}) + * @param agentRunId the agent run ID to claim later + */ + public static void bind(String agentName, String agentRunId) { + BINDINGS.computeIfAbsent(agentName, k -> new ConcurrentLinkedDeque<>()).add(agentRunId); + } + + /** + * Claims (removes and returns) the oldest pending run ID for the given agent name. + * + * @param agentName the agent name + * @return the bound agentRunId, or {@code null} if none pending + */ + public static String claim(String agentName) { + Deque queue = BINDINGS.get(agentName); + return queue == null ? null : queue.poll(); + } +} diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowPlanner.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowPlanner.java index 39c3a44d4e..d3436cc268 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowPlanner.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowPlanner.java @@ -20,6 +20,7 @@ import dev.langchain4j.agentic.planner.InitPlanningContext; import dev.langchain4j.agentic.planner.Planner; import dev.langchain4j.agentic.planner.PlanningContext; +import dev.langchain4j.agentic.scope.AgentInvocation; import dev.langchain4j.agentic.scope.AgenticScope; import dev.langchain4j.service.SystemMessage; import dev.langchain4j.service.UserMessage; @@ -39,7 +40,7 @@ import java.util.UUID; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.locks.ReentrantLock; import java.util.function.BiPredicate; @@ -76,13 +77,6 @@ public record AgentMetadata(String agentName, String userMessage, String systemM public record AgentExchange(AgentInstance agent, CompletableFuture continuation, String agentRunId) { } - /** - * Tracks per-agent completion info so {@link #nextAction} can signal the - * orchestration workflow and clean up after each agent finishes. - */ - private record PendingAgentInfo(String agentRunId) { - } - private final String plannerId; private final Class workflowClass; private final String description; @@ -105,13 +99,14 @@ private record PendingAgentInfo(String agentRunId) { // Conditional configuration private Map> conditions = Collections.emptyMap(); - // Thread-safe deque for parallel agent futures — nextAction() is called from - // different threads (one per agent) in LangChain4j's parallel executor. - private final ConcurrentLinkedDeque> pendingFutures = new ConcurrentLinkedDeque<>(); + // Pending exchanges keyed by AgentInstance.agentId(). nextAction() correlates each + // completion via PlanningContext.previousAgentInvocation().agentId() — completion + // order is arbitrary in parallel execution, so FIFO correlation is incorrect. + private final ConcurrentHashMap pendingByAgentId = new ConcurrentHashMap<>(); - // Thread-safe deque for per-agent completion info — polled in nextAction() - // alongside pendingFutures to signal the orchestration workflow and clean up. - private final ConcurrentLinkedDeque pendingAgentInfos = new ConcurrentLinkedDeque<>(); + // Idempotency guard: agent-call is a durable activity (at-least-once delivery), so + // a redelivered submission for the same agentRunId must not enqueue the agent twice. + private final ConcurrentHashMap> submissionsByRunId = new ConcurrentHashMap<>(); /** * Creates a new DaprWorkflowPlanner. @@ -159,30 +154,38 @@ public Action firstAction(PlanningContext planningContext) { public Action nextAction(PlanningContext planningContext) { // Clear the per-agent Dapr context now that the previous agent has finished. DaprAgentContextHolder.clear(); - // Complete one future per call. LangChain4j calls nextAction() once per agent - // from separate threads in parallel execution. - CompletableFuture future = pendingFutures.poll(); - if (future != null) { - future.complete(null); + + // Correlate the completion to its exchange by agent identity. LangChain4j calls + // nextAction() once per finished agent (from separate threads in parallel + // execution) and tells us WHICH agent finished via previousAgentInvocation. + AgentInvocation finished = planningContext != null + ? planningContext.previousAgentInvocation() : null; + AgentExchange exchange = finished != null + ? pendingByAgentId.remove(finished.agentId()) : null; + + if (finished != null && exchange == null) { + LOG.warnf("[Planner:%s] No pending exchange for completed agent %s (agentId=%s)", + plannerId, finished.agentName(), finished.agentId()); } - // Signal the orchestration workflow that this agent completed and clean up. - PendingAgentInfo info = pendingAgentInfos.poll(); - if (info != null) { + if (exchange != null) { + if (exchange.continuation() != null) { + exchange.continuation().complete(null); + } try { - // Send "done" to the per-agent AgentRunWorkflow - workflowClient.raiseEvent(info.agentRunId(), "agent-event", + // Send "done" to this agent's AgentRunWorkflow + workflowClient.raiseEvent(exchange.agentRunId(), "agent-event", new AgentEvent("done", null, null, null)); LOG.infof("[Planner:%s] Sent done event to AgentRunWorkflow — agentRunId=%s", - plannerId, info.agentRunId()); - DaprAgentRunRegistry.unregister(info.agentRunId()); + plannerId, exchange.agentRunId()); + DaprAgentRunRegistry.unregister(exchange.agentRunId()); // Signal the orchestration workflow that this agent has completed - workflowClient.raiseEvent(plannerId, "agent-complete-" + info.agentRunId(), null); + workflowClient.raiseEvent(plannerId, "agent-complete-" + exchange.agentRunId(), null); LOG.infof("[Planner:%s] Raised agent-complete event — agentRunId=%s", - plannerId, info.agentRunId()); + plannerId, exchange.agentRunId()); } catch (Exception ex) { LOG.warnf("[Planner:%s] Failed to signal agent completion for agentRunId=%s: %s", - plannerId, info.agentRunId(), ex.getMessage()); + plannerId, exchange.agentRunId(), ex.getMessage()); } } @@ -254,13 +257,12 @@ private Action internalNextAction() { return done(); } - // Store all futures — one per agent. nextAction() is called once per agent - // (possibly from different threads), each call polls and completes one future. - pendingFutures.clear(); - pendingAgentInfos.clear(); + // Register all exchanges by agent identity. nextAction() is called once per + // finished agent (possibly from different threads) and removes the matching + // entry. No clear(): entries from a previous batch that have not been signaled + // yet must survive a staggered drain. for (AgentExchange exchange : exchanges) { - pendingFutures.add(exchange.continuation()); - pendingAgentInfos.add(new PendingAgentInfo(exchange.agentRunId())); + pendingByAgentId.put(exchange.agent().agentId(), exchange); } // For sequential execution (single agent), set the Dapr agent context so that @@ -285,9 +287,19 @@ private Action internalNextAction() { * @return a future that completes when the planner has processed this agent */ public CompletableFuture executeAgent(AgentInstance agent, String agentRunId) { - CompletableFuture future = new CompletableFuture<>(); - agentExchangeQueue.add(new AgentExchange(agent, future, agentRunId)); - return future; + if (agentRunId == null) { + // No run id to dedupe on — submit directly. + CompletableFuture future = new CompletableFuture<>(); + agentExchangeQueue.add(new AgentExchange(agent, future, null)); + return future; + } + // Idempotent by agentRunId: agent-call activities are delivered at-least-once, + // and a redelivery must not run the agent a second time. + return submissionsByRunId.computeIfAbsent(agentRunId, id -> { + CompletableFuture future = new CompletableFuture<>(); + agentExchangeQueue.add(new AgentExchange(agent, future, id)); + return future; + }); } /** @@ -444,6 +456,14 @@ public void setConditions(Map> conditions) { private void cleanup() { DaprAgentContextHolder.clear(); + // Release any straggler continuations so no activity thread stays blocked. + pendingByAgentId.values().forEach(ex -> { + if (ex.continuation() != null) { + ex.continuation().complete(null); + } + }); + pendingByAgentId.clear(); + submissionsByRunId.clear(); DaprPlannerRegistry.unregister(plannerId); } } diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/activities/AgentExecutionActivity.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/activities/AgentExecutionActivity.java index 7a72d693a6..63515b1cee 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/activities/AgentExecutionActivity.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/activities/AgentExecutionActivity.java @@ -13,6 +13,7 @@ package io.dapr.quarkus.langchain4j.workflow.orchestration.activities; +import io.dapr.quarkus.langchain4j.agent.AgentRunBindingRegistry; import io.dapr.quarkus.langchain4j.agent.AgentRunContext; import io.dapr.quarkus.langchain4j.agent.DaprAgentRunRegistry; import io.dapr.quarkus.langchain4j.workflow.DaprPlannerRegistry; @@ -66,8 +67,15 @@ public Object run(WorkflowActivityContext ctx) { LOG.infof("[Planner:%s] AgentExecutionActivity started — agent=%s, agentRunId=%s", input.plannerId(), agentName, agentRunId); - AgentRunContext runContext = new AgentRunContext(agentRunId); - DaprAgentRunRegistry.register(agentRunId, runContext); + // Register only once: this activity is delivered at-least-once, and replacing an + // in-flight AgentRunContext would orphan its pending LLM/tool calls. + if (DaprAgentRunRegistry.get(agentRunId) == null) { + DaprAgentRunRegistry.register(agentRunId, new AgentRunContext(agentRunId)); + // Bind agentName → agentRunId so the generated decorator can claim the Dapr + // context on the agent's own thread (required for parallel execution, where + // the planner cannot set the ThreadLocal on LangChain4j's executor threads). + AgentRunBindingRegistry.bind(agentName, agentRunId); + } // Submit the agent to the planner's exchange queue (non-blocking). // The planner's nextAction() handles completion signaling and cleanup. diff --git a/quarkus/runtime/src/test/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowPlannerTest.java b/quarkus/runtime/src/test/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowPlannerTest.java index 70075c0bfa..92a5554075 100644 --- a/quarkus/runtime/src/test/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowPlannerTest.java +++ b/quarkus/runtime/src/test/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowPlannerTest.java @@ -48,10 +48,14 @@ void setUp() { AgenticSystemTopology.SEQUENCE, workflowClient); - agent1 = mock(AgentInstance.class); + // AgentExecutor mocks: Action.call() casts batch members to the internal + // AgentExecutor type, so plain AgentInstance mocks fail inside firstAction. + agent1 = mock(dev.langchain4j.agentic.internal.AgentExecutor.class); when(agent1.name()).thenReturn("agent1"); - agent2 = mock(AgentInstance.class); + when(agent1.agentId()).thenReturn("agent1-id"); + agent2 = mock(dev.langchain4j.agentic.internal.AgentExecutor.class); when(agent2.name()).thenReturn("agent2"); + when(agent2.agentId()).thenReturn("agent2-id"); scope = mock(AgenticScope.class); } @@ -186,6 +190,57 @@ void shouldEvaluateConditionCheck() { assertThat(planner.checkCondition(1)).isFalse(); } + @Test + void executeAgentShouldBeIdempotentByRunId() { + // agent-call activities are at-least-once: a redelivery for the same + // agentRunId must return the SAME future and not enqueue the agent twice. + CompletableFuture first = planner.executeAgent(agent1, "run-1"); + CompletableFuture redelivered = planner.executeAgent(agent1, "run-1"); + + assertThat(redelivered).isSameAs(first); + } + + @Test + void nextActionShouldSignalCompletedAgentByIdentity() throws Exception { + // Two agents submitted; agent2 finishes FIRST (parallel completion order is + // arbitrary). The planner must signal agent2's runId, not FIFO-poll agent1's. + InitPlanningContext initCtx = new InitPlanningContext( + scope, mock(AgentInstance.class), List.of(agent1, agent2)); + planner.init(initCtx); + + planner.executeAgent(agent1, "p:0"); + planner.executeAgent(agent2, "p:1"); + + // Drain the batch (both exchanges) — sentinel posted from another thread so + // the planner's blocking drain in the SECOND call terminates. + Thread sentinel = new Thread(() -> { + try { + Thread.sleep(100); + planner.signalWorkflowComplete(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + sentinel.start(); + + PlanningContext batchCtx = mock(PlanningContext.class); + planner.firstAction(batchCtx); + + // agent2 completes first + PlanningContext agent2Done = mock(PlanningContext.class); + when(agent2Done.previousAgentInvocation()).thenReturn( + new dev.langchain4j.agentic.scope.AgentInvocation( + Object.class, "agent2", "agent2-id", Map.of(), "out")); + planner.nextAction(agent2Done); + + // done + agent-complete went to agent2's runId — NOT agent1's + verify(workflowClient).raiseEvent(eq("p:1"), eq("agent-event"), any()); + verify(workflowClient).raiseEvent( + eq(planner.getPlannerId()), eq("agent-complete-p:1"), any()); + verify(workflowClient, org.mockito.Mockito.never()) + .raiseEvent(eq("p:0"), eq("agent-event"), any()); + } + @Test void shouldHandleConcurrentExecuteAgentCalls() throws Exception { CountDownLatch latch = new CountDownLatch(2); From d0b1fa28eb0a73fbd1d1b2528d9a1f4a6385074d Mon Sep 17 00:00:00 2001 From: Javier Aliaga Date: Thu, 11 Jun 2026 13:43:24 +0200 Subject: [PATCH 16/21] test(quarkus): skip workflow e2e tests on CI until dapr/dapr#10054 ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daprd 1.18.0 save-before-dispatch race (workflow event lost: app-side completion while daprd keeps the workflow RUNNING, event reminder firing on an empty inbox) hangs most e2e requests on slow CI runners — retry-once cannot converge there. Skip StoryResourceTest and ParallelResourceTest when CI=true; they keep running locally where the race is ~1 in 6 and absorbed by the retry. DaprWorkflowClientTest still runs on CI for boot and dev services coverage. Signed-off-by: Javier Aliaga --- quarkus/README.md | 9 ++++++--- .../io/dapr/quarkus/examples/ParallelResourceTest.java | 3 +++ .../java/io/dapr/quarkus/examples/StoryResourceTest.java | 3 +++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/quarkus/README.md b/quarkus/README.md index 4290f5d59f..c419202986 100644 --- a/quarkus/README.md +++ b/quarkus/README.md @@ -170,7 +170,10 @@ dapr.agentic.scope-store.name=kvstore - **Same-named agents in concurrent orchestrations**: completion routing is exact, but the per-thread context binding is claimed FIFO by agent name, so observability may cross-attribute runs between concurrent requests using the same agent name -- **daprd 1.18.0 workflow race**: a child workflow completion can occasionally be lost by - the runtime (child completes app-side but stays RUNNING in daprd, its event reminder - fires on an empty inbox), hanging that request. Tests retry once to absorb it. +- **daprd 1.18.0 workflow race**: a workflow event can be lost by the runtime (the + workflow completes app-side but stays RUNNING in daprd, its event reminder fires on + an empty inbox), hanging that request — fix upstream in + [dapr/dapr#10054](https://github.com/dapr/dapr/pull/10054). The e2e tests retry once + locally and are skipped on CI (slow runners widen the race window) until a fixed + runtime ships. - **Small models**: llama3.2 (3B) sometimes malforms tool call arguments; llama3.1:8b+ recommended 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 index 09a41d9e78..40f8a8aabf 100644 --- a/quarkus/examples/src/test/java/io/dapr/quarkus/examples/ParallelResourceTest.java +++ b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/ParallelResourceTest.java @@ -5,6 +5,7 @@ import static org.hamcrest.Matchers.notNullValue; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable; import org.junit.jupiter.api.extension.ExtendWith; import io.quarkus.test.junit.QuarkusTest; @@ -18,6 +19,8 @@ *

        * Requires Docker for Dapr dev services. Uses {@link MockChatModel} instead of a real LLM. */ +@DisabledIfEnvironmentVariable(named = "CI", matches = "true", + disabledReason = "daprd 1.18.0 save-before-dispatch race (dapr/dapr#10054) loses workflow events; hangs are frequent on slow CI runners. Re-enable when the fixed runtime ships.") @QuarkusTest @ExtendWith(DockerAvailableCondition.class) class ParallelResourceTest { 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 index 5de3a60bd4..2cca68e909 100644 --- a/quarkus/examples/src/test/java/io/dapr/quarkus/examples/StoryResourceTest.java +++ b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/StoryResourceTest.java @@ -4,6 +4,7 @@ import static org.hamcrest.Matchers.notNullValue; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable; import org.junit.jupiter.api.extension.ExtendWith; import io.quarkus.test.junit.QuarkusTest; @@ -15,6 +16,8 @@ * PostgreSQL state store, and dashboard containers via Testcontainers). * Uses {@link MockChatModel} instead of a real LLM. */ +@DisabledIfEnvironmentVariable(named = "CI", matches = "true", + disabledReason = "daprd 1.18.0 save-before-dispatch race (dapr/dapr#10054) loses workflow events; hangs are frequent on slow CI runners. Re-enable when the fixed runtime ships.") @QuarkusTest @ExtendWith(DockerAvailableCondition.class) class StoryResourceTest { From 5946097eb1ea295fe695ccd55364f6753b5118ab Mon Sep 17 00:00:00 2001 From: Javier Aliaga Date: Sat, 13 Jun 2026 11:28:54 +0200 Subject: [PATCH 17/21] fix(quarkus): correct Dapr Conversation ChatModel tool schema and tool_calls handling Two bugs in DaprConversationChatModel surfaced by tool-using @LoopAgent runs over the Dapr Conversation API: - toDaprTool advertised every tool as {"type":"object"} with no properties, so models called tools with empty/guessed arguments. Now maps ToolSpecification.parameters() via JsonSchemaElementUtils.toMap(), keeping the empty-object schema only as the null fallback for parameterless tools. - toDaprMessage left toolCalls null for a tool-less AiMessage (e.g. a prior loop iteration's plain-text answer carried into the next iteration's history), which NPEs in AssistantMessage's List.copyOf(toolCalls). Initialize to an empty list; the alpha2 proto's repeated tool_calls field serializes zero elements identically to unset, so nothing invalid reaches the provider. Adds unit tests for tool JSON-schema mapping, the parameterless fallback, the tool-less AiMessage path, and tool-call round-tripping. --- .../quarkus-langchain4j-dapr/runtime/pom.xml | 19 +++ .../chatmodel/DaprConversationChatModel.java | 24 ++- .../DaprConversationChatModelTest.java | 147 ++++++++++++++++++ 3 files changed, 184 insertions(+), 6 deletions(-) create mode 100644 quarkus/quarkus-langchain4j-dapr/runtime/src/test/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationChatModelTest.java diff --git a/quarkus/quarkus-langchain4j-dapr/runtime/pom.xml b/quarkus/quarkus-langchain4j-dapr/runtime/pom.xml index 2d9816ddbc..13996cb177 100644 --- a/quarkus/quarkus-langchain4j-dapr/runtime/pom.xml +++ b/quarkus/quarkus-langchain4j-dapr/runtime/pom.xml @@ -33,6 +33,25 @@ 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 + diff --git a/quarkus/quarkus-langchain4j-dapr/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationChatModel.java b/quarkus/quarkus-langchain4j-dapr/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationChatModel.java index a9bafa399a..962a6bf5f1 100644 --- a/quarkus/quarkus-langchain4j-dapr/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationChatModel.java +++ b/quarkus/quarkus-langchain4j-dapr/runtime/src/main/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationChatModel.java @@ -19,6 +19,7 @@ import dev.langchain4j.data.message.SystemMessage; import dev.langchain4j.data.message.ToolExecutionResultMessage; import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.internal.JsonSchemaElementUtils; import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.response.ChatResponse; @@ -112,9 +113,14 @@ private ConversationMessage toDaprMessage(ChatMessage msg) { List.of(new ConversationMessageContent(um.singleText()))); } if (msg instanceof AiMessage ai) { - List toolCalls = null; + // Always non-null: AssistantMessage's constructor calls List.copyOf(toolCalls), + // which NPEs on null. A tool-less AiMessage (e.g. a prior iteration's plain-text + // answer carried into a @LoopAgent's follow-up history) must map cleanly. An empty + // list is safe on the wire: the alpha2 proto's tool_calls is a repeated field, so + // zero elements serialize identically to "unset" — daprd cannot distinguish empty + // from absent, so no tool_calls:[] is ever sent to the provider. + List toolCalls = new ArrayList<>(); if (ai.hasToolExecutionRequests()) { - toolCalls = new ArrayList<>(); for (var req : ai.toolExecutionRequests()) { ConversationToolCalls tc = new ConversationToolCalls( new ConversationToolCallsOfFunction(req.name(), req.arguments())); @@ -136,10 +142,16 @@ private ConversationMessage toDaprMessage(ChatMessage msg) { } private ConversationTools toDaprTool(ToolSpecification spec) { - // Use ToolSpecification's JSON Schema representation directly - Map parameters = new HashMap<>(); - parameters.put("type", "object"); - // TODO: map ToolSpecification.parameters() to JSON Schema when available + Map parameters; + if (spec.parameters() != null) { + // Convert the LangChain4j JSON schema (properties, required, ...) so the + // model knows the tool's argument shape + parameters = JsonSchemaElementUtils.toMap(spec.parameters()); + } else { + // Parameterless tool: advertise an empty object schema + parameters = new HashMap<>(); + parameters.put("type", "object"); + } ConversationToolsFunction fn = new ConversationToolsFunction(spec.name(), parameters); if (spec.description() != null) { fn.setDescription(spec.description()); diff --git a/quarkus/quarkus-langchain4j-dapr/runtime/src/test/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationChatModelTest.java b/quarkus/quarkus-langchain4j-dapr/runtime/src/test/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationChatModelTest.java new file mode 100644 index 0000000000..e5e5b2efdd --- /dev/null +++ b/quarkus/quarkus-langchain4j-dapr/runtime/src/test/java/io/dapr/quarkus/langchain4j/chatmodel/DaprConversationChatModelTest.java @@ -0,0 +1,147 @@ +package io.dapr.quarkus.langchain4j.chatmodel; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import dev.langchain4j.agent.tool.ToolExecutionRequest; +import dev.langchain4j.agent.tool.ToolSpecification; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.request.json.JsonObjectSchema; +import io.dapr.client.domain.AssistantMessage; +import io.dapr.client.DaprPreviewClient; +import io.dapr.client.domain.ConversationMessage; +import io.dapr.client.domain.ConversationRequestAlpha2; +import io.dapr.client.domain.ConversationToolCalls; +import io.dapr.client.domain.ConversationToolsFunction; +import reactor.core.publisher.Mono; + +class DaprConversationChatModelTest { + + private DaprPreviewClient client; + private DaprConversationChatModel model; + + @BeforeEach + void setUp() { + client = mock(DaprPreviewClient.class); + when(client.converseAlpha2(any())).thenReturn(Mono.empty()); + model = new DaprConversationChatModel(client, "llm", 0.7); + } + + @Test + void toolParametersShouldCarryJsonSchema() { + ToolSpecification spec = ToolSpecification.builder() + .name("getWeather") + .description("Get the weather for a city") + .parameters(JsonObjectSchema.builder() + .addStringProperty("city", "The city name") + .required("city") + .build()) + .build(); + + model.chat(ChatRequest.builder() + .messages(UserMessage.from("What is the weather in Madrid?")) + .toolSpecifications(spec) + .build()); + + ConversationToolsFunction fn = capturedRequest().getTools().get(0).getFunction(); + assertThat(fn.getName()).isEqualTo("getWeather"); + assertThat(fn.getDescription()).isEqualTo("Get the weather for a city"); + + Map parameters = fn.getParameters(); + assertThat(parameters.get("type")).isEqualTo("object"); + + @SuppressWarnings("unchecked") + Map properties = (Map) parameters.get("properties"); + assertThat(properties).containsKey("city"); + + @SuppressWarnings("unchecked") + List required = (List) parameters.get("required"); + assertThat(required).containsExactly("city"); + } + + @Test + void parameterlessToolShouldFallBackToEmptyObjectSchema() { + ToolSpecification spec = ToolSpecification.builder() + .name("ping") + .build(); + + model.chat(ChatRequest.builder() + .messages(UserMessage.from("ping")) + .toolSpecifications(spec) + .build()); + + Map parameters = + capturedRequest().getTools().get(0).getFunction().getParameters(); + assertThat(parameters).containsExactly(entry("type", "object")); + } + + @Test + void toolLessAiMessageShouldMapWithoutThrowingAndCarryNoToolCalls() { + // Regression: a plain-text AiMessage (no tool requests) — e.g. a prior @LoopAgent + // iteration's final answer carried into the next iteration's history — used to NPE + // in AssistantMessage's List.copyOf(null). + model.chat(ChatRequest.builder() + .messages( + UserMessage.from("Plan my trip"), + AiMessage.from("Here is your itinerary.")) + .build()); + + AssistantMessage assistant = (AssistantMessage) messageOfRole( + capturedRequest(), io.dapr.client.domain.ConversationMessageRole.ASSISTANT); + assertThat(assistant.getContent().get(0).getText()).isEqualTo("Here is your itinerary."); + // Non-null (so the SDK constructor does not NPE) and empty (so no tool_calls are + // serialized — an empty repeated proto field is wire-identical to unset). + assertThat(assistant.getToolCalls()).isNotNull().isEmpty(); + } + + @Test + void aiMessageWithToolRequestsShouldMapToolCalls() { + AiMessage withTool = AiMessage.from(ToolExecutionRequest.builder() + .id("call-1") + .name("getWeather") + .arguments("{\"city\":\"Madrid\"}") + .build()); + + model.chat(ChatRequest.builder() + .messages( + UserMessage.from("Weather in Madrid?"), + withTool) + .build()); + + AssistantMessage assistant = (AssistantMessage) messageOfRole( + capturedRequest(), io.dapr.client.domain.ConversationMessageRole.ASSISTANT); + assertThat(assistant.getToolCalls()).hasSize(1); + ConversationToolCalls tc = assistant.getToolCalls().get(0); + assertThat(tc.getId()).isEqualTo("call-1"); + assertThat(tc.getFunction().getName()).isEqualTo("getWeather"); + assertThat(tc.getFunction().getArguments()).isEqualTo("{\"city\":\"Madrid\"}"); + } + + private static ConversationMessage messageOfRole( + ConversationRequestAlpha2 request, io.dapr.client.domain.ConversationMessageRole role) { + return request.getInputs().get(0).getMessages().stream() + .filter(m -> m.getRole() == role) + .findFirst() + .orElseThrow(); + } + + private ConversationRequestAlpha2 capturedRequest() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(ConversationRequestAlpha2.class); + verify(client).converseAlpha2(captor.capture()); + return captor.getValue(); + } +} From 74e11ccb82068cac81e3de51cf31efc8c446c00c Mon Sep 17 00:00:00 2001 From: Javier Aliaga Date: Sat, 13 Jun 2026 11:29:06 +0200 Subject: [PATCH 18/21] fix(quarkus): stop loop-iteration agents from claiming stale run bindings In a @LoopAgent (or any sequential) orchestration the agent runs on the planner's own loop thread, where the planner already sets the Dapr context. The generated decorator still claimed an agentName->agentRunId binding FIFO, so a later iteration of the same agent picked up an earlier iteration's already-completed run id, routed its LLM call to a dead AgentRunContext, and tripped crash recovery with an empty prompt. - The generated decorator now delegates directly when DaprAgentContextHolder is already set on the thread (sequential/single-agent batch), claiming a binding only on parallel executor threads where the planner cannot set the context. - DaprWorkflowPlanner registers the run context and the name binding once per run id in executeAgent, removes the binding for single-agent batches it routes itself, and purges leftover bindings on cleanup. - AgentExecutionActivity no longer registers/binds (now the planner's job). Adds a 2-iteration LoopWriter example (/loop) and LoopResourceTest asserting both iterations run the normal path and leave no stale registry/binding state. --- .../deployment/DaprAgenticProcessor.java | 44 ++++++++++-- .../dapr/quarkus/examples/LoopResource.java | 48 +++++++++++++ .../io/dapr/quarkus/examples/LoopWriter.java | 33 +++++++++ .../quarkus/examples/LoopResourceTest.java | 68 +++++++++++++++++++ .../agent/AgentRunBindingRegistry.java | 31 +++++++++ .../workflow/DaprWorkflowPlanner.java | 19 +++++- .../activities/AgentExecutionActivity.java | 26 +++---- 7 files changed, 243 insertions(+), 26 deletions(-) create mode 100644 quarkus/examples/src/main/java/io/dapr/quarkus/examples/LoopResource.java create mode 100644 quarkus/examples/src/main/java/io/dapr/quarkus/examples/LoopWriter.java create mode 100644 quarkus/examples/src/test/java/io/dapr/quarkus/examples/LoopResourceTest.java diff --git a/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java b/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java index ee7f9398c5..83838a7249 100644 --- a/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java +++ b/quarkus/deployment/src/main/java/io/dapr/quarkus/langchain4j/deployment/DaprAgenticProcessor.java @@ -541,7 +541,7 @@ private void generateDecorator(ClassOutput classOutput, IndexView index, private void generateDecoratedAgentMethod(ClassCreator cc, MethodInfo method, FieldDescriptor delegateDesc, FieldDescriptor lcmDesc) { - String agentName = extractAgentName(method); + final String agentName = extractAgentName(method); final boolean isVoid = method.returnType().kind() == Type.Kind.VOID; MethodCreator mc = cc.getMethodCreator(MethodDescriptor.of(method)); @@ -550,12 +550,42 @@ private void generateDecoratedAgentMethod(ClassCreator cc, MethodInfo method, mc.addException(exType.name().toString()); } - // Orchestration path: if AgentExecutionActivity bound an agentRunId for this agent, - // claim it and set the Dapr context on THIS thread — LangChain4j's parallel executor - // threads are invisible to the planner, so this is the only place the context can be - // set for parallel agents. The planner's nextAction() handles done-signaling, so the - // bound path delegates directly without the standalone lifecycle (getOrActivate / - // triggerDone). + // Sequential / single-agent path: when an agent is the only one in the planner's + // current batch, LangChain4j runs it on the planner's own loop thread — the same + // thread on which the planner already called DaprAgentContextHolder.set(agentRunId). + // Route through that live context directly; do NOT claim a name-binding. The planner + // owns and clears this context, and a name-keyed claim here could grab a STALE binding + // left by an earlier loop iteration of the same agent name (which would point at an + // already-completed run and wrongly trip crash recovery with an empty prompt). + // + // if (DaprAgentContextHolder.get() != null) { + // return delegate.method(params); // context already set by the planner + // } + ResultHandle liveContext = mc.invokeStaticMethod( + MethodDescriptor.ofMethod(DaprAgentContextHolder.class, "get", String.class)); + BranchResult liveBranch = mc.ifNull(liveContext); + BytecodeCreator hasLive = liveBranch.falseBranch(); + { + ResultHandle liveDelegate = hasLive.readInstanceField(delegateDesc, hasLive.getThis()); + ResultHandle[] liveParams = new ResultHandle[method.parametersCount()]; + for (int i = 0; i < liveParams.length; i++) { + liveParams[i] = hasLive.getMethodParam(i); + } + if (isVoid) { + hasLive.invokeInterfaceMethod(MethodDescriptor.of(method), liveDelegate, liveParams); + hasLive.returnVoid(); + } else { + ResultHandle liveResult = hasLive.invokeInterfaceMethod( + MethodDescriptor.of(method), liveDelegate, liveParams); + hasLive.returnValue(liveResult); + } + } + + // Parallel path: agents run on LangChain4j's executor threads, which are invisible to + // the planner, so it cannot set the context for them. AgentExecutionActivity bound an + // agentRunId for this agent — claim it and set the Dapr context on THIS thread. The + // planner's nextAction() handles done-signaling, so the bound path delegates directly + // without the standalone lifecycle (getOrActivate / triggerDone). // // String claimed = AgentRunBindingRegistry.claim(agentName); // if (claimed != null) { diff --git a/quarkus/examples/src/main/java/io/dapr/quarkus/examples/LoopResource.java b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/LoopResource.java new file mode 100644 index 0000000000..de95b865bd --- /dev/null +++ b/quarkus/examples/src/main/java/io/dapr/quarkus/examples/LoopResource.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.quarkus.examples; + +import jakarta.inject.Inject; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; + +/** + * REST endpoint that triggers the loop creation workflow. + * + *

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

        Example usage: + *

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

        The same sub-agents execute in every iteration, which exercises the per-iteration + * agent-run bookkeeping (registry entries, name bindings, completion routing). + */ +public interface LoopWriter { + + @LoopAgent(name = "loop-writer-agent", + outputKey = "story", + maxIterations = 2, + subAgents = { CreativeWriter.class, StyleEditor.class }) + String write(@V("topic") String topic, @V("style") String style); +} diff --git a/quarkus/examples/src/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..e1ecd2e7d5 --- /dev/null +++ b/quarkus/examples/src/test/java/io/dapr/quarkus/examples/LoopResourceTest.java @@ -0,0 +1,68 @@ +package io.dapr.quarkus.examples; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable; +import org.junit.jupiter.api.extension.ExtendWith; + +import io.dapr.quarkus.langchain4j.agent.AgentRunBindingRegistry; +import io.dapr.quarkus.langchain4j.agent.DaprAgentRunRegistry; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.config.HttpClientConfig; +import io.restassured.config.RestAssuredConfig; + +/** + * Integration test for the loop orchestration workflow. + *

        + * {@link LoopWriter} runs {@link CreativeWriter} and {@link StyleEditor} twice in a loop, + * so the same agents execute in both iterations. This verifies the per-iteration agent-run + * bookkeeping: iteration 1 must run through the normal LLM/tool activity path (not crash + * recovery) and leave no stale registry state behind. + *

        + * Requires Docker for Dapr dev services. Uses {@link MockChatModel} instead of a real LLM. + */ +@DisabledIfEnvironmentVariable(named = "CI", matches = "true", + disabledReason = "daprd 1.18.0 save-before-dispatch race (dapr/dapr#10054) loses workflow events; hangs are frequent on slow CI runners. Re-enable when the fixed runtime ships.") +@QuarkusTest +@ExtendWith(DockerAvailableCondition.class) +class LoopResourceTest { + + /** + * Bound the HTTP wait: when iteration 1 of the loop stalls (e.g. its child agent-run + * never completes), the request would otherwise hang forever. + */ + private static final RestAssuredConfig BOUNDED = RestAssuredConfig.config() + .httpClient(HttpClientConfig.httpClientConfig() + .setParam("http.socket.timeout", 120_000)); + + @Test + void testLoopRunsBothIterationsThroughNormalPath() { + given() + .config(BOUNDED) + .queryParam("topic", "dragons") + .queryParam("style", "comedy") + .when() + .get("/loop") + .then() + .statusCode(200) + .body(notNullValue()) + .body(not("")); + + // All four agent runs (2 iterations x 2 agents) completed: no in-flight run + // contexts may remain registered. + assertTrue(DaprAgentRunRegistry.getRegisteredIds().isEmpty(), + "leftover AgentRunContexts: " + DaprAgentRunRegistry.getRegisteredIds()); + + // Every bound agentRunId must have been claimed by its own iteration's run. + // A leftover binding means a later iteration would claim a stale run id. + assertNull(AgentRunBindingRegistry.claim("creative-writer-agent"), + "stale binding left for creative-writer-agent"); + assertNull(AgentRunBindingRegistry.claim("style-editor-agent"), + "stale binding left for style-editor-agent"); + } +} diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/AgentRunBindingRegistry.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/AgentRunBindingRegistry.java index 3aef3de698..a0ad6e27fb 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/AgentRunBindingRegistry.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/AgentRunBindingRegistry.java @@ -61,4 +61,35 @@ public static String claim(String agentName) { Deque queue = BINDINGS.get(agentName); return queue == null ? null : queue.poll(); } + + /** + * Removes a specific pending binding. Used by the planner when it routes a run on the + * current thread itself (sequential execution): the binding will never be claimed by a + * decorator, and leaving it behind would hand a dead run ID to a later claim of the + * same agent name. + * + * @param agentName the agent name + * @param agentRunId the exact run ID to remove + */ + public static void remove(String agentName, String agentRunId) { + Deque queue = BINDINGS.get(agentName); + if (queue != null) { + queue.remove(agentRunId); + } + } + + /** + * Removes all pending bindings whose run ID starts with the given prefix. Safety net + * used by the planner's cleanup so that an aborted orchestration cannot leak bindings + * into later runs of the same agent names. + * + * @param agentName the agent name + * @param agentRunPrefix the run ID prefix (typically {@code plannerId + ":"}) + */ + public static void purge(String agentName, String agentRunPrefix) { + Deque queue = BINDINGS.get(agentName); + if (queue != null) { + queue.removeIf(id -> id.startsWith(agentRunPrefix)); + } + } } diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowPlanner.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowPlanner.java index d3436cc268..06e346b210 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowPlanner.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/DaprWorkflowPlanner.java @@ -24,6 +24,8 @@ import dev.langchain4j.agentic.scope.AgenticScope; import dev.langchain4j.service.SystemMessage; import dev.langchain4j.service.UserMessage; +import io.dapr.quarkus.langchain4j.agent.AgentRunBindingRegistry; +import io.dapr.quarkus.langchain4j.agent.AgentRunContext; import io.dapr.quarkus.langchain4j.agent.DaprAgentContextHolder; import io.dapr.quarkus.langchain4j.agent.DaprAgentRunRegistry; import io.dapr.quarkus.langchain4j.agent.workflow.AgentEvent; @@ -267,8 +269,13 @@ private Action internalNextAction() { // For sequential execution (single agent), set the Dapr agent context so that // DaprToolCallInterceptor can route @Tool calls through the AgentRunWorkflow. + // The agent runs on this very thread, so its name-binding will never be claimed + // by the generated decorator — remove it, or a later run of the same agent name + // (next loop iteration, next request) would claim this run's dead ID. if (exchanges.size() == 1 && exchanges.get(0).agentRunId() != null) { DaprAgentContextHolder.set(exchanges.get(0).agentRunId()); + AgentRunBindingRegistry.remove( + exchanges.get(0).agent().name(), exchanges.get(0).agentRunId()); } return call(batch); @@ -294,8 +301,13 @@ public CompletableFuture executeAgent(AgentInstance agent, String agentRun return future; } // Idempotent by agentRunId: agent-call activities are delivered at-least-once, - // and a redelivery must not run the agent a second time. + // and a redelivery must not run the agent a second time. Registering the run + // context and the name-binding inside the same guard keeps redeliveries from + // re-creating them after the run completed and was unregistered — a stale + // re-bind would hand this run's dead ID to a later run of the same agent name. return submissionsByRunId.computeIfAbsent(agentRunId, id -> { + DaprAgentRunRegistry.register(id, new AgentRunContext(id)); + AgentRunBindingRegistry.bind(agent.name(), id); CompletableFuture future = new CompletableFuture<>(); agentExchangeQueue.add(new AgentExchange(agent, future, id)); return future; @@ -464,6 +476,11 @@ private void cleanup() { }); pendingByAgentId.clear(); submissionsByRunId.clear(); + // Purge any unclaimed name-bindings from this orchestration (e.g. after an aborted + // run) so they cannot be claimed by a later run of the same agent names. + for (AgentInstance agent : agents) { + AgentRunBindingRegistry.purge(agent.name(), plannerId + ":"); + } DaprPlannerRegistry.unregister(plannerId); } } diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/activities/AgentExecutionActivity.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/activities/AgentExecutionActivity.java index 63515b1cee..865ae7f518 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/activities/AgentExecutionActivity.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/workflow/orchestration/activities/AgentExecutionActivity.java @@ -13,9 +13,6 @@ package io.dapr.quarkus.langchain4j.workflow.orchestration.activities; -import io.dapr.quarkus.langchain4j.agent.AgentRunBindingRegistry; -import io.dapr.quarkus.langchain4j.agent.AgentRunContext; -import io.dapr.quarkus.langchain4j.agent.DaprAgentRunRegistry; import io.dapr.quarkus.langchain4j.workflow.DaprPlannerRegistry; import io.dapr.quarkus.langchain4j.workflow.DaprWorkflowPlanner; import io.dapr.quarkus.langchain4j.workflow.DaprWorkflowPlanner.AgentMetadata; @@ -31,9 +28,10 @@ * child {@link io.dapr.quarkus.langchain4j.agent.workflow.AgentRunWorkflow}, it: *

          *
        1. Looks up the planner from the registry.
        2. - *
        3. Creates a per-agent {@link AgentRunContext} and registers it.
        4. - *
        5. Submits the agent to the planner's exchange queue (along with its {@code agentRunId}) - * so the planner can set {@link io.dapr.quarkus.langchain4j.agent.DaprAgentContextHolder} + *
        6. Submits the agent to the planner's exchange queue (along with its {@code agentRunId}); + * the planner registers the per-agent {@link io.dapr.quarkus.langchain4j.agent.AgentRunContext} + * and the agent-name binding exactly once per run ID, and sets + * {@link io.dapr.quarkus.langchain4j.agent.DaprAgentContextHolder} * on the executing thread before tool calls begin.
        7. *
        8. Returns immediately — the planner's {@code nextAction()} handles completion * signaling (sending {@code "done"} to the AgentRunWorkflow, raising an external @@ -67,18 +65,10 @@ public Object run(WorkflowActivityContext ctx) { LOG.infof("[Planner:%s] AgentExecutionActivity started — agent=%s, agentRunId=%s", input.plannerId(), agentName, agentRunId); - // Register only once: this activity is delivered at-least-once, and replacing an - // in-flight AgentRunContext would orphan its pending LLM/tool calls. - if (DaprAgentRunRegistry.get(agentRunId) == null) { - DaprAgentRunRegistry.register(agentRunId, new AgentRunContext(agentRunId)); - // Bind agentName → agentRunId so the generated decorator can claim the Dapr - // context on the agent's own thread (required for parallel execution, where - // the planner cannot set the ThreadLocal on LangChain4j's executor threads). - AgentRunBindingRegistry.bind(agentName, agentRunId); - } - - // Submit the agent to the planner's exchange queue (non-blocking). - // The planner's nextAction() handles completion signaling and cleanup. + // Submit the agent to the planner's exchange queue (non-blocking). The planner + // registers the AgentRunContext and the agentName → agentRunId binding exactly once + // per run ID (this activity is delivered at-least-once), and its nextAction() + // handles completion signaling and cleanup. planner.executeAgent(planner.getAgent(input.agentIndex()), agentRunId); LOG.infof("[Planner:%s] AgentExecutionActivity submitted — agent=%s, agentRunId=%s", From 2fc5a0cf0174e581d704d40f7415c23b11ad7307 Mon Sep 17 00:00:00 2001 From: Javier Aliaga Date: Sat, 13 Jun 2026 11:29:12 +0200 Subject: [PATCH 19/21] fix(quarkus): self-heal lost agent-events with a bounded wait timeout AgentRunWorkflow's agent loop waited on agent-event with no timeout, so an event that the durabletask runtime persisted to history but never delivered to the in-memory waiter (a side effect of the daprd save-before-dispatch race) left the workflow dormant forever. Add a 60s wait timeout that re-arms on expiry, forcing a fresh execution whose replay delivers the missed event from history. Also collapse the tool-call/llm-call branches into if/else-if and warn on unhandled event types instead of silently dropping them. --- .../agent/workflow/AgentRunWorkflow.java | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/workflow/AgentRunWorkflow.java b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/workflow/AgentRunWorkflow.java index ea3d32fa7c..7977e5377d 100644 --- a/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/workflow/AgentRunWorkflow.java +++ b/quarkus/runtime/src/main/java/io/dapr/quarkus/langchain4j/agent/workflow/AgentRunWorkflow.java @@ -13,6 +13,7 @@ package io.dapr.quarkus.langchain4j.agent.workflow; +import io.dapr.durabletask.TaskCanceledException; import io.dapr.durabletask.TaskFailedException; import io.dapr.quarkus.langchain4j.agent.activities.LlmCallInput; import io.dapr.quarkus.langchain4j.agent.activities.LlmCallOutput; @@ -26,6 +27,7 @@ import jakarta.enterprise.context.ApplicationScoped; import org.jboss.logging.Logger; +import java.time.Duration; import java.util.ArrayList; import java.util.List; /** @@ -62,6 +64,22 @@ public class AgentRunWorkflow implements Workflow { private static final Logger LOG = Logger.getLogger(AgentRunWorkflow.class); + /** + * Timeout for each {@code waitForExternalEvent} arm of the agent loop. + * + *

          The wait is NOT expected to time out in normal operation — events + * arrive within milliseconds of being raised. The timeout exists as a + * durable self-heal wake-up: durabletask-java can process the first work + * item built after a sidecar-side actor state reload entirely in replay + * mode and silently drop a new external event instead of delivering it to + * the waiter registered in that same execution (the event IS persisted to + * history; only the in-memory delivery is lost, leaving the workflow + * dormant on an eternal timer). With a finite timeout the next timer + * firing forces a fresh execution whose replay delivers the missed event + * from history, turning a permanent stall into a bounded delay. + */ + private static final Duration EVENT_WAIT_TIMEOUT = Duration.ofSeconds(60); + @Override public WorkflowStub create() { return ctx -> { @@ -82,7 +100,18 @@ public WorkflowStub create() { LOG.infof("[AgentRun:%s][iter:%d] Waiting for agent-event (replay=%s)", agentRunId, eventIndex, ctx.isReplaying()); - AgentEvent event = ctx.waitForExternalEvent("agent-event", AgentEvent.class).await(); + AgentEvent event; + try { + event = ctx.waitForExternalEvent("agent-event", EVENT_WAIT_TIMEOUT, AgentEvent.class).await(); + } catch (TaskCanceledException tce) { + // Wait timed out: no event was delivered within the window. Loop + // and re-arm — the fresh execution replays history, so an event + // that was persisted but never delivered to this waiter (see + // EVENT_WAIT_TIMEOUT) is picked up on this pass. + LOG.debugf("[AgentRun:%s][iter:%d] agent-event wait timed out after %s; re-arming (replay=%s)", + agentRunId, eventIndex, EVENT_WAIT_TIMEOUT, ctx.isReplaying()); + continue; + } LOG.infof("[AgentRun:%s][iter:%d] Received event: type=%s, callId=%s (replay=%s)", agentRunId, eventIndex, event.type(), event.toolCallId(), ctx.isReplaying()); @@ -104,9 +133,7 @@ public WorkflowStub create() { LOG.infof("[AgentRun:%s][iter:%d] POST-callActivity tool-call=%s → %s", agentRunId, eventIndex, event.toolName(), toolOutput.result()); ctx.setCustomStatus(new AgentRunOutput(agentName, toolCallOutputs, llmCallOutputs, null)); - } - - if ("llm-call".equals(event.type())) { + } else if ("llm-call".equals(event.type())) { LOG.infof("[AgentRun:%s][iter:%d] PRE-callActivity llm-call=%s (replay=%s)", agentRunId, eventIndex, event.toolName(), ctx.isReplaying()); LlmCallOutput llmOutput = ctx.callActivity( @@ -117,6 +144,13 @@ public WorkflowStub create() { LOG.infof("[AgentRun:%s][iter:%d] POST-callActivity llm-call=%s → %s", agentRunId, eventIndex, event.toolName(), llmOutput.response()); ctx.setCustomStatus(new AgentRunOutput(agentName, toolCallOutputs, llmCallOutputs, null)); + } else { + // Never silently swallow an event: an unrecognized (or null) + // type here means a producer/serialization bug, and dropping it + // would strand whoever raised it waiting for a response. + LOG.warnf("[AgentRun:%s][iter:%d] UNHANDLED agent-event type=%s, callId=%s — ignoring; " + + "this indicates an event producer or serialization bug", + agentRunId, eventIndex, event.type(), event.toolCallId()); } } catch (TaskFailedException e) { // Activity failed — the in-memory AgentRunContext is gone after a crash. From 768aeae74d44a19dc8e7635dd18247b6549ee15d Mon Sep 17 00:00:00 2001 From: Javier Aliaga Date: Sat, 13 Jun 2026 11:37:35 +0200 Subject: [PATCH 20/21] docs(quarkus): reflect loop-binding and event self-heal fixes in limitations - Narrow the same-agent-name caveat: within one orchestration (loop iterations, sequential agents) each run is bound to its own run ID; only genuinely concurrent cross-request parallel runs of the same name can interleave observability. - Note AgentRunWorkflow's bounded agent-event wait timeout self-heals events lost to the daprd save-before-dispatch race (bounded delay instead of a permanent hang). --- quarkus/README.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/quarkus/README.md b/quarkus/README.md index c419202986..b627f2d5e0 100644 --- a/quarkus/README.md +++ b/quarkus/README.md @@ -167,13 +167,19 @@ dapr.agentic.scope-store.name=kvstore ## Known Limitations - **Recovery granularity**: Agent-level only — individual LLM/tool calls within an agent are re-executed (not skipped) -- **Same-named agents in concurrent orchestrations**: completion routing is exact, but the - per-thread context binding is claimed FIFO by agent name, so observability may - cross-attribute runs between concurrent requests using the same agent name +- **Same agent name in concurrent _parallel_ orchestrations**: within a single + orchestration each run's Dapr context is bound to its own run ID — including every + iteration of a `@LoopAgent` and all sequential agents, which run on the planner's + thread and route through the context it sets directly. Only when two *different* + concurrent requests run the same agent name on parallel executor threads at the same + instant can the FIFO name binding cross-attribute their runs; run IDs stay unique so + routing is still correct, but observability may interleave. - **daprd 1.18.0 workflow race**: a workflow event can be lost by the runtime (the workflow completes app-side but stays RUNNING in daprd, its event reminder fires on - an empty inbox), hanging that request — fix upstream in - [dapr/dapr#10054](https://github.com/dapr/dapr/pull/10054). The e2e tests retry once - locally and are skipped on CI (slow runners widen the race window) until a fixed - runtime ships. + an empty inbox) — fix upstream in + [dapr/dapr#10054](https://github.com/dapr/dapr/pull/10054). `AgentRunWorkflow` now + self-heals: each `agent-event` wait has a 60s timeout that re-arms and lets replay + redeliver the missed event from history, turning a permanent hang into a bounded + delay. The e2e tests still retry once locally and are skipped on CI (slow runners + widen the race window) until a fixed runtime ships. - **Small models**: llama3.2 (3B) sometimes malforms tool call arguments; llama3.1:8b+ recommended From 4c274fc25402458b2dce35d0cc6399cd245d86c5 Mon Sep 17 00:00:00 2001 From: Javier Aliaga Date: Mon, 15 Jun 2026 10:28:19 +0200 Subject: [PATCH 21/21] docs(quarkus): document single-replica execution constraint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live agent execution keeps per-run state in JVM memory (the AgentRunContext registry, the CompletableFuture the agent thread blocks on, and the per-thread Dapr context), but Dapr Workflow randomly load-balances activities across all replicas of an app-id with no locality to where a run started. With more than one replica an LLM/tool activity can land on a replica that lacks the in-memory context, fail to find it, and surface as a false crash-recovery while the originating request blocks until timeout. Document the single-replica requirement and that the fix is control inversion (state in workflow history) — the same root cause as agent-level (not per-call) recovery. --- quarkus/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/quarkus/README.md b/quarkus/README.md index b627f2d5e0..de8dba424b 100644 --- a/quarkus/README.md +++ b/quarkus/README.md @@ -166,6 +166,16 @@ dapr.agentic.scope-store.name=kvstore ## Known Limitations +- **Single replica only (current design)**: live agent execution keeps per-run state in + JVM memory — the `AgentRunContext` registry, the `CompletableFuture` the agent thread + blocks on, and the per-thread Dapr context. Dapr Workflow, however, + [randomly load-balances activities across all replicas](https://docs.dapr.io/developing-applications/building-blocks/workflow/workflow-architecture/) + of an app-id, with no locality to where a run started. With more than one replica an + LLM/tool activity can be dispatched to a replica that lacks the in-memory context, fail + to find it, and surface as a (false) crash-recovery while the originating request blocks + until the call timeout. **Deploy a single replica** until execution state is moved into + workflow history (control inversion — see [Crash Recovery](#crash-recovery)); this is the + same root cause as agent-level (not per-call) recovery. - **Recovery granularity**: Agent-level only — individual LLM/tool calls within an agent are re-executed (not skipped) - **Same agent name in concurrent _parallel_ orchestrations**: within a single orchestration each run's Dapr context is bound to its own run ID — including every