From 7d4946c063907cb1cfc46ecbbca5667d544e70c7 Mon Sep 17 00:00:00 2001 From: salaboy Date: Tue, 10 Mar 2026 14:47:16 +0000 Subject: [PATCH 1/6] initial commit with quarkus modules Signed-off-by: salaboy --- pom.xml | 1 + quarkus/README.md | 2 + quarkus/deployment/pom.xml | 60 ++ .../deployment/DaprAgenticProcessor.java | 592 ++++++++++++++++++ quarkus/examples/pom.xml | 95 +++ .../dapr/examples/CreativeWriter.java | 21 + .../dapr/examples/ParallelCreator.java | 30 + .../dapr/examples/ParallelResource.java | 36 ++ .../dapr/examples/ParallelStatus.java | 4 + .../dapr/examples/ResearchResource.java | 42 ++ .../dapr/examples/ResearchTools.java | 45 ++ .../dapr/examples/ResearchWriter.java | 35 ++ .../dapr/examples/StoryCreator.java | 19 + .../dapr/examples/StoryResource.java | 32 + .../dapr/examples/StyleEditor.java | 20 + .../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 | 46 ++ .../pom.xml | 92 +++ .../agents/registry/model/AgentMetadata.java | 154 +++++ .../registry/model/AgentMetadataSchema.java | 208 ++++++ .../agents/registry/model/LLMMetadata.java | 152 +++++ .../agents/registry/model/MemoryMetadata.java | 54 ++ .../agents/registry/model/PubSubMetadata.java | 68 ++ .../registry/model/RegistryMetadata.java | 51 ++ .../agents/registry/model/ToolMetadata.java | 68 ++ .../registry/service/AgentRegistry.java | 239 +++++++ .../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 | 79 +++ .../agent/AgentRunLifecycleManager.java | 121 ++++ .../agent/DaprAgentContextHolder.java | 28 + .../agent/DaprAgentInterceptorBinding.java | 25 + .../agent/DaprAgentMetadataHolder.java | 32 + .../agent/DaprAgentMethodInterceptor.java | 119 ++++ .../agent/DaprAgentRunRegistry.java | 36 ++ .../DaprAgentToolInterceptorBinding.java | 24 + .../agent/DaprChatModelDecorator.java | 257 ++++++++ .../agent/DaprToolCallInterceptor.java | 124 ++++ .../agent/activities/LlmCallActivity.java | 117 ++++ .../agent/activities/LlmCallInput.java | 17 + .../agent/activities/LlmCallOutput.java | 16 + .../agent/activities/ToolCallActivity.java | 86 +++ .../agent/activities/ToolCallInput.java | 13 + .../agent/activities/ToolCallOutput.java | 13 + .../agent/workflow/AgentEvent.java | 23 + .../agent/workflow/AgentRunInput.java | 18 + .../agent/workflow/AgentRunOutput.java | 23 + .../agent/workflow/AgentRunWorkflow.java | 114 ++++ .../memory/KeyValueChatMemoryStore.java | 64 ++ .../workflow/DaprAgentService.java | 14 + .../workflow/DaprAgentServiceUtil.java | 22 + .../workflow/DaprConditionalAgentService.java | 104 +++ .../workflow/DaprLoopAgentService.java | 109 ++++ .../workflow/DaprParallelAgentService.java | 55 ++ .../workflow/DaprPlannerRegistry.java | 29 + .../workflow/DaprSequentialAgentService.java | 55 ++ .../workflow/DaprWorkflowAgentsBuilder.java | 64 ++ .../workflow/DaprWorkflowPlanner.java | 382 +++++++++++ .../workflow/WorkflowNameResolver.java | 26 + .../orchestration/AgentExecInput.java | 12 + .../orchestration/ConditionCheckInput.java | 10 + .../ConditionalOrchestrationWorkflow.java | 54 ++ .../ExitConditionCheckInput.java | 10 + .../LoopOrchestrationWorkflow.java | 72 +++ .../orchestration/OrchestrationInput.java | 12 + .../ParallelOrchestrationWorkflow.java | 62 ++ .../SequentialOrchestrationWorkflow.java | 49 ++ .../activities/AgentExecutionActivity.java | 69 ++ .../activities/ConditionCheckActivity.java | 28 + .../ExitConditionCheckActivity.java | 28 + .../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 ++ 94 files changed, 7342 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 910570b36..bda0304d9 100644 --- a/pom.xml +++ b/pom.xml @@ -715,6 +715,7 @@ testcontainers-dapr durabletask-client + quarkus diff --git a/quarkus/README.md b/quarkus/README.md new file mode 100644 index 000000000..a6379ee17 --- /dev/null +++ b/quarkus/README.md @@ -0,0 +1,2 @@ +# quarkus-agentic-dapr +Quarkus Agentic Dapr Workflows integration diff --git a/quarkus/deployment/pom.xml b/quarkus/deployment/pom.xml new file mode 100644 index 000000000..ccf6cc0f2 --- /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.quarkiverse.dapr + 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 000000000..df2201514 --- /dev/null +++ b/quarkus/deployment/src/main/java/io/quarkiverse/dapr/langchain4j/deployment/DaprAgenticProcessor.java @@ -0,0 +1,592 @@ +package io.quarkiverse.dapr.langchain4j.deployment; + +import java.lang.reflect.Modifier; +import java.util.HashSet; +import java.util.Set; + +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 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; + +/** + * 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.quarkiverse.dapr", "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)); + } + // 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); + 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) { + + 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 s = value.asString(); + return (s == null || s.isEmpty()) ? null : s; + } + + // ------------------------------------------------------------------------- + // 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 000000000..d7ce91880 --- /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.quarkiverse.dapr + quarkus-agentic-dapr + ${project.version} + + + + io.quarkiverse.dapr + 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 000000000..5149081fd --- /dev/null +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/CreativeWriter.java @@ -0,0 +1,21 @@ +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 000000000..74a22db8e --- /dev/null +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelCreator.java @@ -0,0 +1,30 @@ +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); + + @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); + } +} \ No newline at end of file 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 000000000..07a6b17b0 --- /dev/null +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelResource.java @@ -0,0 +1,36 @@ +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); + } +} \ No newline at end of file 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 000000000..fc9a1b300 --- /dev/null +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelStatus.java @@ -0,0 +1,4 @@ +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 000000000..d74ed48d7 --- /dev/null +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchResource.java @@ -0,0 +1,42 @@ +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); + } +} \ No newline at end of file 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 000000000..7ab1859f2 --- /dev/null +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchTools.java @@ -0,0 +1,45 @@ +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 { + + @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."; + }; + } + + @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."; + }; + } +} \ No newline at end of file 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 000000000..bbc2ed94e --- /dev/null +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchWriter.java @@ -0,0 +1,35 @@ +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); +} \ No newline at end of file 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 000000000..3d6a59590 --- /dev/null +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StoryCreator.java @@ -0,0 +1,19 @@ +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 000000000..03d9c1c54 --- /dev/null +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StoryResource.java @@ -0,0 +1,32 @@ +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 000000000..811ae4409 --- /dev/null +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StyleEditor.java @@ -0,0 +1,20 @@ +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 000000000..0e623d4f5 --- /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 \ No newline at end of file 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 000000000..228152710 --- /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 000000000..0747891d1 --- /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 000000000..2a0d4a232 --- /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 000000000..03ce2263e --- /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 000000000..d0d110bce --- /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 000000000..e437b9459 --- /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 000000000..64cfd6634 --- /dev/null +++ b/quarkus/pom.xml @@ -0,0 +1,46 @@ + + + 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 + + + + 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 000000000..37f51de9b --- /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 000000000..b3bfe0c32 --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/AgentMetadata.java @@ -0,0 +1,154 @@ +package io.quarkiverse.dapr.agents.registry.model; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +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; + } + + public AgentMetadata build() { + if (appId == null || type == null) { + throw new IllegalStateException("appId and type are required"); + } + return new AgentMetadata(this); + } + } +} \ No newline at end of file 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 000000000..5d196e2c5 --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/AgentMetadataSchema.java @@ -0,0 +1,208 @@ +package io.quarkiverse.dapr.agents.registry.model; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +@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; + } + + 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; + } + + 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); + } + } +} \ No newline at end of file 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 000000000..d7d28000a --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/LLMMetadata.java @@ -0,0 +1,152 @@ +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; + } + + 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 000000000..f515d4539 --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/MemoryMetadata.java @@ -0,0 +1,54 @@ +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; + } + + public MemoryMetadata build() { + if (type == null) { + throw new IllegalStateException("type is required"); + } + return new MemoryMetadata(this); + } + } +} \ No newline at end of file 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 000000000..c178c1f96 --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/PubSubMetadata.java @@ -0,0 +1,68 @@ +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; + } + + public PubSubMetadata build() { + if (name == null) { + throw new IllegalStateException("name is required"); + } + return new PubSubMetadata(this); + } + } +} \ No newline at end of file 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 000000000..373451ddb --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/RegistryMetadata.java @@ -0,0 +1,51 @@ +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); + } + } +} \ No newline at end of file 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 000000000..b7ab3b96e --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/ToolMetadata.java @@ -0,0 +1,68 @@ +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; + } + + public ToolMetadata build() { + if (toolName == null || toolDescription == null || toolArgs == null) { + throw new IllegalStateException("toolName, toolDescription, and toolArgs are required"); + } + return new ToolMetadata(this); + } + } +} \ No newline at end of file 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 000000000..2910933e0 --- /dev/null +++ b/quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/service/AgentRegistry.java @@ -0,0 +1,239 @@ +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()); + + int registered = 0; + int failed = 0; + 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 + 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; + } + } + + 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 000000000..162171f0f --- /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 000000000..5e926ac93 --- /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 000000000..5a90dad4e --- /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 000000000..7a155c65b --- /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 000000000..306d23f26 --- /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 000000000..56d5865fb --- /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 000000000..e61666bff --- /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 000000000..7edb7721b --- /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 000000000..c9b76a36a --- /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 000000000..d58450e0d --- /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 000000000..f8017f98d --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/AgentRunContext.java @@ -0,0 +1,79 @@ +package io.quarkiverse.dapr.langchain4j.agent; + +import java.lang.reflect.Method; +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. + */ + public record PendingCall( + Object target, + Method method, + Object[] args, + CompletableFuture resultFuture) { + } + + private final String agentRunId; + private final Map pendingCalls = new ConcurrentHashMap<>(); + + public AgentRunContext(String agentRunId) { + this.agentRunId = agentRunId; + } + + 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. + */ + 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. + */ + 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}. + */ + 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. + */ + public void failCall(String toolCallId, Throwable cause) { + PendingCall call = pendingCalls.remove(toolCallId); + if (call != null) { + call.resultFuture().completeExceptionally(cause); + } + } +} \ No newline at end of file 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 000000000..8e2ddafa0 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/AgentRunLifecycleManager.java @@ -0,0 +1,121 @@ +package io.quarkiverse.dapr.langchain4j.agent; + +import java.util.UUID; + +import org.jboss.logging.Logger; + +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; + +/** + * 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} + */ + 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. + */ + 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 000000000..5c4cdc8ad --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentContextHolder.java @@ -0,0 +1,28 @@ +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() { + } + + public static void set(String agentRunId) { + AGENT_RUN_ID.set(agentRunId); + } + + public static String get() { + return AGENT_RUN_ID.get(); + } + + public static void clear() { + AGENT_RUN_ID.remove(); + } +} \ No newline at end of file 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 000000000..e0422d34f --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentInterceptorBinding.java @@ -0,0 +1,25 @@ +package io.quarkiverse.dapr.langchain4j.agent; + +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; + +import jakarta.interceptor.InterceptorBinding; + +/** + * 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 000000000..0509b4055 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentMetadataHolder.java @@ -0,0 +1,32 @@ +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 { + + public record AgentMetadata(String agentName, String userMessage, String systemMessage) { + } + + private static final ThreadLocal METADATA = new ThreadLocal<>(); + + private DaprAgentMetadataHolder() { + } + + public static void set(String agentName, String userMessage, String systemMessage) { + METADATA.set(new AgentMetadata(agentName, userMessage, systemMessage)); + } + + public static AgentMetadata get() { + return METADATA.get(); + } + + public static void clear() { + METADATA.remove(); + } +} \ No newline at end of file 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 000000000..689e179fa --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentMethodInterceptor.java @@ -0,0 +1,119 @@ +package io.quarkiverse.dapr.langchain4j.agent; + +import java.lang.reflect.Method; +import java.util.UUID; + +import org.jboss.logging.Logger; + +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; + +/** + * 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; + + @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; + } +} \ No newline at end of file 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 000000000..750c0ef83 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentRunRegistry.java @@ -0,0 +1,36 @@ +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() { + } + + public static void register(String agentRunId, AgentRunContext context) { + REGISTRY.put(agentRunId, context); + } + + public static AgentRunContext get(String agentRunId) { + return REGISTRY.get(agentRunId); + } + + public static void unregister(String agentRunId) { + REGISTRY.remove(agentRunId); + } + + public static Set getRegisteredIds() { + return REGISTRY.keySet(); + } +} \ No newline at end of file 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 000000000..e05d692e7 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprAgentToolInterceptorBinding.java @@ -0,0 +1,24 @@ +package io.quarkiverse.dapr.langchain4j.agent; + +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; + +import jakarta.interceptor.InterceptorBinding; + +/** + * 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 { +} \ No newline at end of file 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 000000000..9b4f96f93 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprChatModelDecorator.java @@ -0,0 +1,257 @@ +package io.quarkiverse.dapr.langchain4j.agent; + +import java.lang.reflect.Method; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import org.jboss.logging.Logger; + +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; + +/** + * 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 e) { + LOG.warnf("[AgentRun:%s][LlmCall:%s] Could not find chat(ChatRequest) via reflection" + + " — falling back to direct call: %s", agentRunId, llmCallId, e.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 e) { + LOG.debugf("Could not lazily activate AgentRunWorkflow (no active request scope?): %s", + e.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 e) { + 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 { + java.util.List messages = (java.util.List) request.getClass().getMethod("messages").invoke(request); + for (int i = messages.size() - 1; i >= 0; i--) { + Object msg = messages.get(i); + if ("UserMessage".equals(msg.getClass().getSimpleName())) { + try { + return (String) msg.getClass().getMethod("singleText").invoke(msg); + } catch (Exception e) { + return String.valueOf(msg); + } + } + } + } catch (Exception ignored) { + } + 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 { + java.util.List messages = (java.util.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 (Exception e) { + return String.valueOf(msg); + } + } + } + } catch (Exception ignored) { + } + 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 000000000..2747f3044 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/DaprToolCallInterceptor.java @@ -0,0 +1,124 @@ +package io.quarkiverse.dapr.langchain4j.agent; + +import java.util.Arrays; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import org.jboss.logging.Logger; + +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; + +/** + * 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; + + @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(); + 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; + } + } +} \ No newline at end of file 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 000000000..5e01bea72 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/LlmCallActivity.java @@ -0,0 +1,117 @@ +package io.quarkiverse.dapr.langchain4j.agent.activities; + +import io.quarkiverse.dapr.workflows.ActivityMetadata; +import org.jboss.logging.Logger; + +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 jakarta.enterprise.context.ApplicationScoped; + +/** + * 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); + + @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 (java.lang.reflect.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 (Exception 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 000000000..639beba05 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/LlmCallInput.java @@ -0,0 +1,17 @@ +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) { +} \ No newline at end of file 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 000000000..b60ab4782 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/LlmCallOutput.java @@ -0,0 +1,16 @@ +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) { +} \ No newline at end of file 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 000000000..c63f73ab0 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/ToolCallActivity.java @@ -0,0 +1,86 @@ +package io.quarkiverse.dapr.langchain4j.agent.activities; + +import io.quarkiverse.dapr.workflows.ActivityMetadata; +import org.jboss.logging.Logger; + +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 jakarta.enterprise.context.ApplicationScoped; + +/** + * 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 (java.lang.reflect.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(); + } + } +} \ No newline at end of file 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 000000000..9e902bc6e --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/ToolCallInput.java @@ -0,0 +1,13 @@ +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) { +} \ No newline at end of file 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 000000000..629b0e5f0 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/activities/ToolCallOutput.java @@ -0,0 +1,13 @@ +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) { +} \ No newline at end of file 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 000000000..945b65548 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/workflow/AgentEvent.java @@ -0,0 +1,23 @@ +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) { +} \ No newline at end of file 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 000000000..9fdce087a --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/workflow/AgentRunInput.java @@ -0,0 +1,18 @@ +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) { +} \ No newline at end of file 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 000000000..dd48456d9 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/workflow/AgentRunOutput.java @@ -0,0 +1,23 @@ +package io.quarkiverse.dapr.langchain4j.agent.workflow; + +import java.util.List; + +import io.quarkiverse.dapr.langchain4j.agent.activities.LlmCallOutput; +import io.quarkiverse.dapr.langchain4j.agent.activities.ToolCallOutput; + +/** + * 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) { +} \ No newline at end of file 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 000000000..60dbdb1cf --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/workflow/AgentRunWorkflow.java @@ -0,0 +1,114 @@ +package io.quarkiverse.dapr.langchain4j.agent.workflow; + +import java.util.ArrayList; +import java.util.List; + +import io.quarkiverse.dapr.workflows.WorkflowMetadata; +import org.jboss.logging.Logger; + +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 jakarta.enterprise.context.ApplicationScoped; + +/** + * 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 000000000..b8353e27d --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/memory/KeyValueChatMemoryStore.java @@ -0,0 +1,64 @@ +package io.quarkiverse.dapr.langchain4j.memory; + +import java.util.Collections; +import java.util.List; +import java.util.function.Function; + +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; + +/** + * 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; + + public KeyValueChatMemoryStore(DaprClient daprClient, String stateStoreName) { + this(daprClient, stateStoreName, + ChatMessageSerializer::messagesToJson, + ChatMessageDeserializer::messagesFromJson); + } + + 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(); + } +} \ No newline at end of file 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 000000000..dd9c37fea --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprAgentService.java @@ -0,0 +1,14 @@ +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. + */ + 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 000000000..f89fbcd7d --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprAgentServiceUtil.java @@ -0,0 +1,22 @@ +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. + */ + 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 000000000..b078ba038 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprConditionalAgentService.java @@ -0,0 +1,104 @@ +package io.quarkiverse.dapr.langchain4j.workflow; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Predicate; + +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; + +/** + * 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; + + 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); + } + + @Override + public String workflowType() { + return ConditionalOrchestrationWorkflow.class.getCanonicalName(); + } + + @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; + } + + @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; + } + + @Override + public DaprConditionalAgentService subAgent(Predicate condition, AgentExecutor agent) { + daprConditions.put(agentCounter, condition); + agentCounter++; + super.subAgent(condition, agent); + return this; + } + + @Override + public DaprConditionalAgentService subAgent(String conditionDescription, + Predicate condition, AgentExecutor agent) { + daprConditions.put(agentCounter, condition); + agentCounter++; + super.subAgent(conditionDescription, condition, agent); + return this; + } + + @Override + public T build() { + return build(() -> { + DaprWorkflowPlanner planner = new DaprWorkflowPlanner( + ConditionalOrchestrationWorkflow.class, + "Conditional", + AgenticSystemTopology.ROUTER, + workflowClient); + planner.setConditions(daprConditions); + return planner; + }); + } + + public static DaprConditionalAgentService builder(DaprWorkflowClient workflowClient) { + return new DaprConditionalAgentService<>(UntypedAgent.class, workflowClient); + } + + 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 000000000..5c7710647 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprLoopAgentService.java @@ -0,0 +1,109 @@ +package io.quarkiverse.dapr.langchain4j.workflow; + +import java.util.function.BiPredicate; +import java.util.function.Predicate; + +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; + +/** + * 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; + + public DaprLoopAgentService(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, 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; + } + + @Override + public DaprLoopAgentService exitCondition(String description, Predicate exitCondition) { + this.daprExitCondition = (scope, iter) -> exitCondition.test(scope); + super.exitCondition(description, exitCondition); + return this; + } + + @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; + }); + } + + public static DaprLoopAgentService builder(DaprWorkflowClient workflowClient) { + return new DaprLoopAgentService<>(UntypedAgent.class, workflowClient); + } + + 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 000000000..048962abd --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprParallelAgentService.java @@ -0,0 +1,55 @@ +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; + + 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)); + } + + public static DaprParallelAgentService builder(DaprWorkflowClient workflowClient) { + return new DaprParallelAgentService<>(UntypedAgent.class, workflowClient); + } + + 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 000000000..a6d20b3bd --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprPlannerRegistry.java @@ -0,0 +1,29 @@ +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<>(); + + public static void register(String id, DaprWorkflowPlanner planner) { + registry.put(id, planner); + } + + public static DaprWorkflowPlanner get(String id) { + return registry.get(id); + } + + public static void unregister(String id) { + registry.remove(id); + } + + 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 000000000..701ae9c63 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprSequentialAgentService.java @@ -0,0 +1,55 @@ +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; + + 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)); + } + + public static DaprSequentialAgentService builder(DaprWorkflowClient workflowClient) { + return new DaprSequentialAgentService<>(UntypedAgent.class, workflowClient); + } + + 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 000000000..16ee54a17 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprWorkflowAgentsBuilder.java @@ -0,0 +1,64 @@ +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 { + + 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 000000000..4bfdfc08b --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprWorkflowPlanner.java @@ -0,0 +1,382 @@ +package io.quarkiverse.dapr.langchain4j.workflow; + +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; + +import org.jboss.logging.Logger; + +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; + +/** + * 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. + */ + 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<>(); + + 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 e) { + LOG.warnf("[Planner:%s] Failed to signal agent completion for agentRunId=%s: %s", + plannerId, info.agentRunId(), e.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 e) { + 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. + */ + 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}. + */ + public AgentMetadata getAgentMetadata(int index) { + AgentInstance agent = agents.get(index); + String agentName = agent.name(); + String userMessage = null; + String systemMessage = 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) { + userMessage = String.join("\n", userAnnotation.value()); + } + SystemMessage systemAnnotation = method.getAnnotation(SystemMessage.class); + if (systemAnnotation != null && systemAnnotation.value().length > 0) { + systemMessage = String.join("\n", systemAnnotation.value()); + } + break; + } + } + } + } catch (Exception e) { + LOG.debugf("Could not extract prompt metadata from agent type for agent=%s: %s", + agentName, e.getMessage()); + } + + return new AgentMetadata(agentName, userMessage, systemMessage); + } + + /** + * Returns the agentic scope. + */ + public AgenticScope getAgenticScope() { + return agenticScope; + } + + /** + * Evaluates the exit condition for loop workflows. + */ + public boolean checkExitCondition(int iteration) { + if (exitCondition == null) { + return false; + } + return exitCondition.test(agenticScope, iteration); + } + + /** + * Evaluates whether a conditional 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); + } + + public String getPlannerId() { + return plannerId; + } + + public int getAgentCount() { + return agents.size(); + } + + // Configuration setters (called by agent service builders) + + public void setMaxIterations(int maxIterations) { + this.maxIterations = maxIterations; + } + + public void setExitCondition(BiPredicate exitCondition) { + this.exitCondition = exitCondition; + } + + public void setTestExitAtLoopEnd(boolean testExitAtLoopEnd) { + this.testExitAtLoopEnd = testExitAtLoopEnd; + } + + public void setConditions(Map> conditions) { + this.conditions = conditions; + } + + private void cleanup() { + DaprAgentContextHolder.clear(); + DaprPlannerRegistry.unregister(plannerId); + } +} \ No newline at end of file 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 000000000..e0f014c02 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/WorkflowNameResolver.java @@ -0,0 +1,26 @@ +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. + */ + 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 000000000..b64ff77fc --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/AgentExecInput.java @@ -0,0 +1,12 @@ +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 000000000..3a2d55d3b --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ConditionCheckInput.java @@ -0,0 +1,10 @@ +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 000000000..0761fd773 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ConditionalOrchestrationWorkflow.java @@ -0,0 +1,54 @@ +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(); + } + }; + } +} \ No newline at end of file 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 000000000..d664633d8 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ExitConditionCheckInput.java @@ -0,0 +1,10 @@ +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 000000000..35c447f75 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/LoopOrchestrationWorkflow.java @@ -0,0 +1,72 @@ +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 000000000..0823a25a6 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/OrchestrationInput.java @@ -0,0 +1,12 @@ +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 000000000..a23bb7f59 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/ParallelOrchestrationWorkflow.java @@ -0,0 +1,62 @@ +package io.quarkiverse.dapr.langchain4j.workflow.orchestration; + +import java.util.ArrayList; +import java.util.List; + +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; + +/** + * 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(); + } + }; + } +} \ No newline at end of file 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 000000000..5ecab8fb8 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/SequentialOrchestrationWorkflow.java @@ -0,0 +1,49 @@ +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 000000000..efd380b1a --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/activities/AgentExecutionActivity.java @@ -0,0 +1,69 @@ +package io.quarkiverse.dapr.langchain4j.workflow.orchestration.activities; + +import io.quarkiverse.dapr.workflows.ActivityMetadata; +import org.jboss.logging.Logger; + +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 jakarta.enterprise.context.ApplicationScoped; + +/** + * 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 000000000..6f4e3eb97 --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/activities/ConditionCheckActivity.java @@ -0,0 +1,28 @@ +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 000000000..61be4a7bc --- /dev/null +++ b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/orchestration/activities/ExitConditionCheckActivity.java @@ -0,0 +1,28 @@ +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 000000000..5ef11eeba --- /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 000000000..ba35d9f09 --- /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 000000000..26ae43a11 --- /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 000000000..8a5704afe --- /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 000000000..1fb7df20d --- /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 000000000..a073c7038 --- /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 000000000..7734be103 --- /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 000000000..dc2b367e5 --- /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 000000000..042f00173 --- /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 000000000..7a0dfb879 --- /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); + } +} From ca44394565c7d533c527b958e6d12e91954e7ec6 Mon Sep 17 00:00:00 2001 From: Javier Aliaga Date: Tue, 10 Mar 2026 16:19:08 +0100 Subject: [PATCH 2/6] chore: Removed unused protos (#1690) Signed-off-by: Javier Aliaga Signed-off-by: salaboy --- .../dapr/durabletask/DurableTaskClient.java | 27 +-- .../durabletask/DurableTaskGrpcClient.java | 46 +--- .../durabletask/DurableTaskGrpcWorker.java | 2 - .../durabletask/OrchestrationStatusQuery.java | 217 ------------------ .../OrchestrationStatusQueryResult.java | 53 ----- .../dapr/durabletask/DurableTaskClientIT.java | 182 --------------- 6 files changed, 2 insertions(+), 525 deletions(-) delete mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationStatusQuery.java delete mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationStatusQueryResult.java diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskClient.java b/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskClient.java index 7f8791803..b08ffcc53 100644 --- a/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskClient.java +++ b/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskClient.java @@ -14,6 +14,7 @@ package io.dapr.durabletask; import javax.annotation.Nullable; + import java.time.Duration; import java.util.concurrent.TimeoutException; @@ -235,32 +236,6 @@ public abstract OrchestrationMetadata waitForInstanceCompletion( */ public abstract void terminate(String instanceId, @Nullable Object output); - /** - * Fetches orchestration instance metadata from the configured durable store using a status query filter. - * - * @param query filter criteria that determines which orchestrations to fetch data for. - * @return the result of the query operation, including instance metadata and possibly a continuation token - */ - public abstract OrchestrationStatusQueryResult queryInstances(OrchestrationStatusQuery query); - - /** - * Initializes the target task hub data store. - * - *

This is an administrative operation that only needs to be done once for the lifetime of the task hub.

- * - * @param recreateIfExists true to delete any existing task hub first; false to make this - * operation a no-op if the task hub data store already exists. Note that deleting a task - * hub will result in permanent data loss. Use this operation with care. - */ - public abstract void createTaskHub(boolean recreateIfExists); - - /** - * Permanently deletes the target task hub data store and any orchestration data it may contain. - * - *

This is an administrative operation that is irreversible. It should be used with great care.

- */ - public abstract void deleteTaskHub(); - /** * Purges orchestration instance metadata from the durable store. * diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcClient.java b/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcClient.java index 881b9e958..e66e6f308 100644 --- a/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcClient.java +++ b/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcClient.java @@ -32,13 +32,12 @@ import io.opentelemetry.api.trace.Tracer; import javax.annotation.Nullable; + import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.time.Duration; import java.time.Instant; -import java.util.ArrayList; -import java.util.List; import java.util.Optional; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -307,49 +306,6 @@ public void terminate(String instanceId, @Nullable Object output) { this.sidecarClient.terminateInstance(builder.build()); } - @Override - public OrchestrationStatusQueryResult queryInstances(OrchestrationStatusQuery query) { - OrchestratorService.InstanceQuery.Builder instanceQueryBuilder = OrchestratorService.InstanceQuery.newBuilder(); - Optional.ofNullable(query.getCreatedTimeFrom()).ifPresent(createdTimeFrom -> - instanceQueryBuilder.setCreatedTimeFrom(DataConverter.getTimestampFromInstant(createdTimeFrom))); - Optional.ofNullable(query.getCreatedTimeTo()).ifPresent(createdTimeTo -> - instanceQueryBuilder.setCreatedTimeTo(DataConverter.getTimestampFromInstant(createdTimeTo))); - Optional.ofNullable(query.getContinuationToken()).ifPresent(token -> - instanceQueryBuilder.setContinuationToken(StringValue.of(token))); - Optional.ofNullable(query.getInstanceIdPrefix()).ifPresent(prefix -> - instanceQueryBuilder.setInstanceIdPrefix(StringValue.of(prefix))); - instanceQueryBuilder.setFetchInputsAndOutputs(query.isFetchInputsAndOutputs()); - instanceQueryBuilder.setMaxInstanceCount(query.getMaxInstanceCount()); - query.getRuntimeStatusList().forEach(runtimeStatus -> - Optional.ofNullable(runtimeStatus).ifPresent(status -> - instanceQueryBuilder.addRuntimeStatus(OrchestrationRuntimeStatus.toProtobuf(status)))); - query.getTaskHubNames().forEach(taskHubName -> Optional.ofNullable(taskHubName).ifPresent(name -> - instanceQueryBuilder.addTaskHubNames(StringValue.of(name)))); - OrchestratorService.QueryInstancesResponse queryInstancesResponse = this.sidecarClient - .queryInstances(OrchestratorService.QueryInstancesRequest.newBuilder().setQuery(instanceQueryBuilder).build()); - return toQueryResult(queryInstancesResponse, query.isFetchInputsAndOutputs()); - } - - private OrchestrationStatusQueryResult toQueryResult( - OrchestratorService.QueryInstancesResponse queryInstancesResponse, boolean fetchInputsAndOutputs) { - List metadataList = new ArrayList<>(); - queryInstancesResponse.getOrchestrationStateList().forEach(state -> { - metadataList.add(new OrchestrationMetadata(state, this.dataConverter, fetchInputsAndOutputs)); - }); - return new OrchestrationStatusQueryResult(metadataList, queryInstancesResponse.getContinuationToken().getValue()); - } - - @Override - public void createTaskHub(boolean recreateIfExists) { - this.sidecarClient.createTaskHub(OrchestratorService.CreateTaskHubRequest.newBuilder() - .setRecreateIfExists(recreateIfExists).build()); - } - - @Override - public void deleteTaskHub() { - this.sidecarClient.deleteTaskHub(OrchestratorService.DeleteTaskHubRequest.newBuilder().build()); - } - @Override public PurgeResult purgeInstance(String instanceId) { OrchestratorService.PurgeInstancesRequest request = OrchestratorService.PurgeInstancesRequest.newBuilder() diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcWorker.java b/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcWorker.java index 806e1dfc5..1e08d0804 100644 --- a/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcWorker.java +++ b/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcWorker.java @@ -197,8 +197,6 @@ public void startAndBlock() { this.workerPool.submit(new ActivityRunner(workItem, taskActivityExecutor, sidecarClient, tracer)); - } else if (requestType == OrchestratorService.WorkItem.RequestCase.HEALTHPING) { - // No-op } else { logger.log(Level.WARNING, "Received and dropped an unknown '{0}' work-item from the sidecar.", diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationStatusQuery.java b/durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationStatusQuery.java deleted file mode 100644 index 864fc37c8..000000000 --- a/durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationStatusQuery.java +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Copyright 2025 The Dapr Authors - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and -limitations under the License. -*/ - -package io.dapr.durabletask; - -import javax.annotation.Nullable; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; - -/** - * Class used for constructing orchestration metadata queries. - */ -public final class OrchestrationStatusQuery { - private List runtimeStatusList = new ArrayList<>(); - private Instant createdTimeFrom; - private Instant createdTimeTo; - private List taskHubNames = new ArrayList<>(); - private int maxInstanceCount = 100; - private String continuationToken; - private String instanceIdPrefix; - private boolean fetchInputsAndOutputs; - - /** - * Sole constructor. - */ - public OrchestrationStatusQuery() { - } - - /** - * Sets the list of runtime status values to use as a filter. Only orchestration instances that have a matching - * runtime status will be returned. The default {@code null} value will disable runtime status filtering. - * - * @param runtimeStatusList the list of runtime status values to use as a filter - * @return this query object - */ - public OrchestrationStatusQuery setRuntimeStatusList(@Nullable List runtimeStatusList) { - this.runtimeStatusList = runtimeStatusList; - return this; - } - - /** - * Include orchestration instances that were created after the specified instant. - * - * @param createdTimeFrom the minimum orchestration creation time to use as a filter or {@code null} to disable this - * filter - * @return this query object - */ - public OrchestrationStatusQuery setCreatedTimeFrom(@Nullable Instant createdTimeFrom) { - this.createdTimeFrom = createdTimeFrom; - return this; - } - - /** - * Include orchestration instances that were created before the specified instant. - * - * @param createdTimeTo the maximum orchestration creation time to use as a filter or {@code null} to disable this - * filter - * @return this query object - */ - public OrchestrationStatusQuery setCreatedTimeTo(@Nullable Instant createdTimeTo) { - this.createdTimeTo = createdTimeTo; - return this; - } - - /** - * Sets the maximum number of records that can be returned by the query. The default value is 100. - * - *

Requests may return fewer records than the specified page size, even if there are more records. - * Always check the continuation token to determine whether there are more records.

- * - * @param maxInstanceCount the maximum number of orchestration metadata records to return - * @return this query object - */ - public OrchestrationStatusQuery setMaxInstanceCount(int maxInstanceCount) { - this.maxInstanceCount = maxInstanceCount; - return this; - } - - /** - * Include orchestration metadata records that have a matching task hub name. - * - * @param taskHubNames the task hub name to match or {@code null} to disable this filter - * @return this query object - */ - public OrchestrationStatusQuery setTaskHubNames(@Nullable List taskHubNames) { - this.taskHubNames = taskHubNames; - return this; - } - - /** - * Sets the continuation token used to continue paging through orchestration metadata results. - * - *

This should always be the continuation token value from the previous query's - * {@link OrchestrationStatusQueryResult} result.

- * - * @param continuationToken the continuation token from the previous query - * @return this query object - */ - public OrchestrationStatusQuery setContinuationToken(@Nullable String continuationToken) { - this.continuationToken = continuationToken; - return this; - } - - /** - * Include orchestration metadata records with the specified instance ID prefix. - * - *

For example, if there are three orchestration instances in the metadata store with IDs "Foo", "Bar", and "Baz", - * specifying a prefix value of "B" will exclude "Foo" since its ID doesn't start with "B".

- * - * @param instanceIdPrefix the instance ID prefix filter value - * @return this query object - */ - public OrchestrationStatusQuery setInstanceIdPrefix(@Nullable String instanceIdPrefix) { - this.instanceIdPrefix = instanceIdPrefix; - return this; - } - - /** - * Sets whether to fetch orchestration inputs, outputs, and custom status values. The default value is {@code false}. - * - * @param fetchInputsAndOutputs {@code true} to fetch orchestration inputs, outputs, and custom status values, - * otherwise {@code false} - * @return this query object - */ - public OrchestrationStatusQuery setFetchInputsAndOutputs(boolean fetchInputsAndOutputs) { - this.fetchInputsAndOutputs = fetchInputsAndOutputs; - return this; - } - - /** - * Gets the configured runtime status filter or {@code null} if none was configured. - * - * @return the configured runtime status filter as a list of values or {@code null} if none was configured - */ - public List getRuntimeStatusList() { - return runtimeStatusList; - } - - /** - * Gets the configured minimum orchestration creation time or {@code null} if none was configured. - * - * @return the configured minimum orchestration creation time or {@code null} if none was configured - */ - @Nullable - public Instant getCreatedTimeFrom() { - return createdTimeFrom; - } - - /** - * Gets the configured maximum orchestration creation time or {@code null} if none was configured. - * - * @return the configured maximum orchestration creation time or {@code null} if none was configured - */ - @Nullable - public Instant getCreatedTimeTo() { - return createdTimeTo; - } - - /** - * Gets the configured maximum number of records that can be returned by the query. - * - * @return the configured maximum number of records that can be returned by the query - */ - public int getMaxInstanceCount() { - return maxInstanceCount; - } - - /** - * Gets the configured task hub names to match or {@code null} if none were configured. - * - * @return the configured task hub names to match or {@code null} if none were configured - */ - public List getTaskHubNames() { - return taskHubNames; - } - - /** - * Gets the configured continuation token value or {@code null} if none was configured. - * - * @return the configured continuation token value or {@code null} if none was configured - */ - @Nullable - public String getContinuationToken() { - return continuationToken; - } - - /** - * Gets the configured instance ID prefix filter value or {@code null} if none was configured. - * - * @return the configured instance ID prefix filter value or {@code null} if none was configured. - */ - @Nullable - public String getInstanceIdPrefix() { - return instanceIdPrefix; - } - - /** - * Gets the configured value that determines whether to fetch orchestration inputs, outputs, and custom status values. - * - * @return the configured value that determines whether to fetch orchestration inputs, outputs, and custom - * status values - */ - public boolean isFetchInputsAndOutputs() { - return fetchInputsAndOutputs; - } -} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationStatusQueryResult.java b/durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationStatusQueryResult.java deleted file mode 100644 index efb4908c1..000000000 --- a/durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationStatusQueryResult.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright 2025 The Dapr Authors - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and -limitations under the License. -*/ - -package io.dapr.durabletask; - -import javax.annotation.Nullable; -import java.util.List; - -/** - * Class representing the results of a filtered orchestration metadata query. - * - *

Orchestration metadata can be queried with filters using the {@link DurableTaskClient#queryInstances} method.

- */ -public final class OrchestrationStatusQueryResult { - private final List orchestrationStates; - private final String continuationToken; - - OrchestrationStatusQueryResult(List orchestrationStates, @Nullable String continuationToken) { - this.orchestrationStates = orchestrationStates; - this.continuationToken = continuationToken; - } - - /** - * Gets the list of orchestration metadata records that matched the {@link DurableTaskClient#queryInstances} query. - * - * @return the list of orchestration metadata records that matched the {@link DurableTaskClient#queryInstances} query. - */ - public List getOrchestrationState() { - return this.orchestrationStates; - } - - /** - * Gets the continuation token to use with the next query or {@code null} if no more metadata records are found. - * - *

Note that a non-null value does not always mean that there are more metadata records that can be returned by a - * query.

- * - * @return the continuation token to use with the next query or {@code null} if no more metadata records are found. - */ - public String getContinuationToken() { - return this.continuationToken; - } -} diff --git a/durabletask-client/src/test/java/io/dapr/durabletask/DurableTaskClientIT.java b/durabletask-client/src/test/java/io/dapr/durabletask/DurableTaskClientIT.java index abf146c7c..dcd43dc49 100644 --- a/durabletask-client/src/test/java/io/dapr/durabletask/DurableTaskClientIT.java +++ b/durabletask-client/src/test/java/io/dapr/durabletask/DurableTaskClientIT.java @@ -1100,188 +1100,6 @@ void clearCustomStatus() throws TimeoutException { } } - // due to clock drift, client/worker and sidecar time are not exactly synchronized, this test needs to accommodate for client vs backend timestamps difference - @Test - @Disabled("Test is disabled for investigation, fixing the test retry pattern exposed the failure") - void multiInstanceQuery() throws TimeoutException { - final String plusOne = "plusOne"; - final String waitForEvent = "waitForEvent"; - final DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); - DurableTaskGrpcWorker worker = this.createWorkerBuilder() - .addOrchestrator(plusOne, ctx -> { - int value = ctx.getInput(int.class); - for (int i = 0; i < 10; i++) { - value = ctx.callActivity(plusOne, value, int.class).await(); - } - ctx.complete(value); - }) - .addActivity(plusOne, ctx -> ctx.getInput(int.class) + 1) - .addOrchestrator(waitForEvent, ctx -> { - String name = ctx.getInput(String.class); - String output = ctx.waitForExternalEvent(name, String.class).await(); - ctx.complete(output); - }).buildAndStart(); - - try (worker; client) { - Instant startTime = Instant.now(); - String prefix = startTime.toString(); - - IntStream.range(0, 5).mapToObj(i -> { - String instanceId = String.format("%s.sequence.%d", prefix, i); - client.scheduleNewOrchestrationInstance(plusOne, 0, instanceId); - return instanceId; - }).collect(Collectors.toUnmodifiableList()).forEach(id -> { - try { - client.waitForInstanceCompletion(id, defaultTimeout, true); - } catch (TimeoutException e) { - e.printStackTrace(); - } - }); - - try { - Thread.sleep(2000); - } catch (InterruptedException e) { - } - - Instant sequencesFinishedTime = Instant.now(); - - IntStream.range(0, 5).mapToObj(i -> { - String instanceId = String.format("%s.waiter.%d", prefix, i); - client.scheduleNewOrchestrationInstance(waitForEvent, String.valueOf(i), instanceId); - return instanceId; - }).collect(Collectors.toUnmodifiableList()).forEach(id -> { - try { - client.waitForInstanceStart(id, defaultTimeout); - } catch (TimeoutException e) { - e.printStackTrace(); - } - }); - - // Create one query object and reuse it for multiple queries - OrchestrationStatusQuery query = new OrchestrationStatusQuery(); - OrchestrationStatusQueryResult result = null; - - // Return all instances - result = client.queryInstances(query); - assertEquals(10, result.getOrchestrationState().size()); - - // Test CreatedTimeTo filter - query.setCreatedTimeTo(startTime.minus(Duration.ofSeconds(1))); - result = client.queryInstances(query); - assertTrue(result.getOrchestrationState().isEmpty(), - "Result should be empty but found " + result.getOrchestrationState().size() + " instances: " + - "Start time: " + startTime + ", " + - result.getOrchestrationState().stream() - .map(state -> String.format("\nID: %s, Status: %s, Created: %s", - state.getInstanceId(), - state.getRuntimeStatus(), - state.getCreatedAt())) - .collect(Collectors.joining(", "))); - - query.setCreatedTimeTo(sequencesFinishedTime); - result = client.queryInstances(query); - // Verify all returned instances contain "sequence" in their IDs - assertEquals(5, result.getOrchestrationState().stream() - .filter(state -> state.getInstanceId().contains("sequence")) - .count(), - "Expected exactly 5 instances with 'sequence' in their IDs"); - - query.setCreatedTimeTo(Instant.now().plus(Duration.ofSeconds(1))); - result = client.queryInstances(query); - assertEquals(10, result.getOrchestrationState().size()); - - // Test CreatedTimeFrom filter - query.setCreatedTimeFrom(Instant.now().plus(Duration.ofSeconds(1))); - result = client.queryInstances(query); - assertTrue(result.getOrchestrationState().isEmpty()); - - query.setCreatedTimeFrom(sequencesFinishedTime.minus(Duration.ofSeconds(5))); - result = client.queryInstances(query); - assertEquals(5, result.getOrchestrationState().stream() - .filter(state -> state.getInstanceId().contains("sequence")) - .count(), - "Expected exactly 5 instances with 'sequence' in their IDs"); - - query.setCreatedTimeFrom(startTime.minus(Duration.ofSeconds(1))); - result = client.queryInstances(query); - assertEquals(10, result.getOrchestrationState().size()); - - // Test RuntimeStatus filter - HashSet statusFilters = Stream.of( - OrchestrationRuntimeStatus.PENDING, - OrchestrationRuntimeStatus.FAILED, - OrchestrationRuntimeStatus.TERMINATED - ).collect(Collectors.toCollection(HashSet::new)); - - query.setRuntimeStatusList(new ArrayList<>(statusFilters)); - result = client.queryInstances(query); - assertTrue(result.getOrchestrationState().isEmpty()); - - statusFilters.add(OrchestrationRuntimeStatus.RUNNING); - query.setRuntimeStatusList(new ArrayList<>(statusFilters)); - result = client.queryInstances(query); - assertEquals(5, result.getOrchestrationState().size()); - - statusFilters.add(OrchestrationRuntimeStatus.COMPLETED); - query.setRuntimeStatusList(new ArrayList<>(statusFilters)); - result = client.queryInstances(query); - assertEquals(10, result.getOrchestrationState().size()); - - statusFilters.remove(OrchestrationRuntimeStatus.RUNNING); - query.setRuntimeStatusList(new ArrayList<>(statusFilters)); - result = client.queryInstances(query); - assertEquals(5, result.getOrchestrationState().size()); - - statusFilters.clear(); - query.setRuntimeStatusList(new ArrayList<>(statusFilters)); - result = client.queryInstances(query); - assertEquals(10, result.getOrchestrationState().size()); - - // Test InstanceIdPrefix - query.setInstanceIdPrefix("Foo"); - result = client.queryInstances(query); - assertTrue(result.getOrchestrationState().isEmpty()); - - query.setInstanceIdPrefix(prefix); - result = client.queryInstances(query); - assertEquals(10, result.getOrchestrationState().size()); - - // Test PageSize and ContinuationToken - HashSet instanceIds = new HashSet<>(); - query.setMaxInstanceCount(0); - while (query.getMaxInstanceCount() < 10) { - query.setMaxInstanceCount(query.getMaxInstanceCount() + 1); - result = client.queryInstances(query); - int total = result.getOrchestrationState().size(); - assertEquals(query.getMaxInstanceCount(), total); - result.getOrchestrationState().forEach(state -> assertTrue(instanceIds.add(state.getInstanceId()))); - while (total < 10) { - query.setContinuationToken(result.getContinuationToken()); - result = client.queryInstances(query); - int count = result.getOrchestrationState().size(); - assertNotEquals(0, count); - assertTrue(count <= query.getMaxInstanceCount()); - total += count; - assertTrue(total <= 10); - result.getOrchestrationState().forEach(state -> assertTrue(instanceIds.add(state.getInstanceId()))); - } - query.setContinuationToken(null); - instanceIds.clear(); - } - - // Test ShowInput - query.setFetchInputsAndOutputs(true); - query.setCreatedTimeFrom(sequencesFinishedTime); - result = client.queryInstances(query); - result.getOrchestrationState().forEach(state -> assertNotNull(state.readInputAs(String.class))); - - query.setFetchInputsAndOutputs(false); - query.setCreatedTimeFrom(sequencesFinishedTime); - result = client.queryInstances(query); - result.getOrchestrationState().forEach(state -> assertThrows(IllegalStateException.class, () -> state.readInputAs(String.class))); - } - } - @Test void purgeInstanceId() throws TimeoutException { final String orchestratorName = "PurgeInstance"; From fce3b4ed789dedd31bf94aa84df4b215f04334bb Mon Sep 17 00:00:00 2001 From: salaboy Date: Tue, 10 Mar 2026 18:04:27 +0000 Subject: [PATCH 3/6] fixing checkstyle and deps Signed-off-by: salaboy --- pom.xml | 6 +- quarkus/deployment/pom.xml | 2 +- .../deployment/DaprAgenticProcessor.java | 1075 +++++++++-------- quarkus/examples/pom.xml | 4 +- .../dapr/examples/CreativeWriter.java | 30 +- .../dapr/examples/ParallelCreator.java | 46 +- .../dapr/examples/ParallelResource.java | 43 +- .../dapr/examples/ParallelStatus.java | 13 + .../dapr/examples/ResearchResource.java | 39 +- .../dapr/examples/ResearchTools.java | 75 +- .../dapr/examples/ResearchWriter.java | 43 +- .../dapr/examples/StoryCreator.java | 25 +- .../dapr/examples/StoryResource.java | 37 +- .../dapr/examples/StyleEditor.java | 30 +- .../src/main/resources/application.properties | 2 +- quarkus/pom.xml | 9 + .../agents/registry/model/AgentMetadata.java | 252 ++-- .../registry/model/AgentMetadataSchema.java | 340 +++--- .../agents/registry/model/LLMMetadata.java | 248 ++-- .../agents/registry/model/MemoryMetadata.java | 94 +- .../agents/registry/model/PubSubMetadata.java | 116 +- .../registry/model/RegistryMetadata.java | 81 +- .../agents/registry/model/ToolMetadata.java | 116 +- .../registry/service/AgentRegistry.java | 50 +- .../model/AgentMetadataSchemaTest.java | 10 +- .../langchain4j/agent/AgentRunContext.java | 149 ++- .../agent/AgentRunLifecycleManager.java | 192 +-- .../agent/DaprAgentContextHolder.java | 56 +- .../agent/DaprAgentInterceptorBinding.java | 24 +- .../agent/DaprAgentMetadataHolder.java | 65 +- .../agent/DaprAgentMethodInterceptor.java | 166 +-- .../agent/DaprAgentRunRegistry.java | 74 +- .../DaprAgentToolInterceptorBinding.java | 23 +- .../agent/DaprChatModelDecorator.java | 369 +++--- .../agent/DaprToolCallInterceptor.java | 195 +-- .../agent/activities/LlmCallActivity.java | 165 +-- .../agent/activities/LlmCallInput.java | 19 +- .../agent/activities/LlmCallOutput.java | 15 +- .../agent/activities/ToolCallActivity.java | 116 +- .../agent/activities/ToolCallInput.java | 21 +- .../agent/activities/ToolCallOutput.java | 15 +- .../agent/workflow/AgentEvent.java | 27 +- .../agent/workflow/AgentRunInput.java | 15 +- .../agent/workflow/AgentRunOutput.java | 25 +- .../agent/workflow/AgentRunWorkflow.java | 144 ++- .../memory/KeyValueChatMemoryStore.java | 113 +- .../workflow/DaprAgentService.java | 25 +- .../workflow/DaprAgentServiceUtil.java | 40 +- .../workflow/DaprConditionalAgentService.java | 193 +-- .../workflow/DaprLoopAgentService.java | 227 ++-- .../workflow/DaprParallelAgentService.java | 91 +- .../workflow/DaprPlannerRegistry.java | 61 +- .../workflow/DaprSequentialAgentService.java | 91 +- .../workflow/DaprWorkflowAgentsBuilder.java | 92 +- .../workflow/DaprWorkflowPlanner.java | 742 ++++++------ .../workflow/WorkflowNameResolver.java | 38 +- .../orchestration/AgentExecInput.java | 13 + .../orchestration/ConditionCheckInput.java | 13 + .../ConditionalOrchestrationWorkflow.java | 73 +- .../ExitConditionCheckInput.java | 13 + .../LoopOrchestrationWorkflow.java | 106 +- .../orchestration/OrchestrationInput.java | 13 + .../ParallelOrchestrationWorkflow.java | 92 +- .../SequentialOrchestrationWorkflow.java | 63 +- .../activities/AgentExecutionActivity.java | 70 +- .../activities/ConditionCheckActivity.java | 29 +- .../ExitConditionCheckActivity.java | 29 +- 67 files changed, 4063 insertions(+), 2825 deletions(-) diff --git a/pom.xml b/pom.xml index bda0304d9..b26acb29c 100644 --- a/pom.xml +++ b/pom.xml @@ -38,9 +38,9 @@ 3.12.1 3.7.0 3.4.2 - 11 - 11 - 11 + 17 + 17 + 17 true 2.16.2 true diff --git a/quarkus/deployment/pom.xml b/quarkus/deployment/pom.xml index ccf6cc0f2..bb75c756d 100644 --- a/quarkus/deployment/pom.xml +++ b/quarkus/deployment/pom.xml @@ -33,7 +33,7 @@ gizmo - io.quarkiverse.dapr + io.dapr.quarkus quarkus-agentic-dapr ${project.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 index df2201514..bc528d60e 100644 --- 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 @@ -1,18 +1,17 @@ -package io.quarkiverse.dapr.langchain4j.deployment; - -import java.lang.reflect.Modifier; -import java.util.HashSet; -import java.util.Set; +/* + * 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. +*/ -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; +package io.quarkiverse.dapr.langchain4j.deployment; import io.quarkiverse.dapr.deployment.items.WorkflowItemBuildItem; import io.quarkiverse.dapr.langchain4j.agent.AgentRunLifecycleManager; @@ -43,12 +42,25 @@ 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. + * + *

{@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 @@ -64,529 +76,572 @@ * through Dapr Workflow Activities without requiring user code changes.
  2. *
  3. 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 + * of the agent method call -- before LangChain4j assembles the prompt -- giving Dapr * full observability of the agent's lifecycle from its first instruction.
  4. *
*/ 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.quarkiverse.dapr", "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)); - } + 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.quarkiverse.dapr", "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)); + } } - /** - * 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)); + 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"); } - // 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")); + workflowItems.produce(new WorkflowItemBuildItem( + classInfo, WorkflowItemBuildItem.Type.WORKFLOW_ACTIVITY, regName, null, null)); + } } - - /** - * 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); - } + } + + /** + * 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)); } - - // ------------------------------------------------------------------------- - // 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); - } - } - } + for (String className : ACTIVITY_CLASSES) { + additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf(className)); } - - /** - * 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); - 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); + // 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; } - 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(); + if (method.hasAnnotation(AGENT_ANNOTATION)) { + generateDecoratedAgentMethod(cc, method, delegateDesc, lcmDesc); } else { - tryBlock.returnValue(result); + 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()); + } - // 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()); + // 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); + } } - /** - * 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) { - - 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()); - } + // Activation succeeded -- wrap the delegate call with triggerDone() on both paths. + // try { ... } catch (Throwable t) { ... } + TryBlock tryBlock = mc.tryBlock(); - 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); - } + 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); } - // ------------------------------------------------------------------------- - // 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(); + // 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); } - /** - * 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(); + 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); } - // ------------------------------------------------------------------------- - // Annotation metadata extraction helpers - // ------------------------------------------------------------------------- - - private static String stringValueOrNull(AnnotationInstance annotation, String name) { - AnnotationValue value = annotation.value(name); - if (value == null) { - return null; - } - String s = value.asString(); - return (s == null || s.isEmpty()) ? null : s; + // 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()); } - // ------------------------------------------------------------------------- - // 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))); + 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); } - /** - * 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))); + if (isVoid) { + mc.invokeInterfaceMethod(MethodDescriptor.of(method), del, params); + mc.returnVoid(); + } else { + ResultHandle result = mc.invokeInterfaceMethod( + MethodDescriptor.of(method), del, params); + mc.returnValue(result); } - - /** - * 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))); + } + + // ------------------------------------------------------------------------- + // 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 index d7ce91880..2e8ac2639 100644 --- a/quarkus/examples/pom.xml +++ b/quarkus/examples/pom.xml @@ -16,13 +16,13 @@ - io.quarkiverse.dapr + io.dapr.quarkus quarkus-agentic-dapr ${project.version} - io.quarkiverse.dapr + io.dapr.quarkus quarkus-agentic-dapr-agents-registry ${project.version} 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 index 5149081fd..ec67d9a47 100644 --- a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/CreativeWriter.java +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/CreativeWriter.java @@ -1,3 +1,16 @@ +/* + * 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; @@ -10,12 +23,13 @@ */ 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); + @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 index 74a22db8e..30277c329 100644 --- a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelCreator.java +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelCreator.java @@ -1,3 +1,16 @@ +/* + * 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; @@ -7,24 +20,31 @@ /** * 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}. + * + *

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); + @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); + /** + * 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); } -} \ No newline at end of file + 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 index 07a6b17b0..52a44879b 100644 --- a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelResource.java +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelResource.java @@ -1,3 +1,16 @@ +/* + * 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; @@ -10,11 +23,11 @@ /** * REST endpoint that triggers the parallel creation workflow. - *

- * Runs {@link StoryCreator} (a nested {@code @SequenceAgent}) and {@link ResearchWriter} + * + *

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

- * Example usage: + * + *

Example usage: *

  * curl "http://localhost:8080/parallel?topic=dragons&country=France&style=comedy"
  * 
@@ -22,15 +35,15 @@ @Path("/parallel") public class ParallelResource { - @Inject - ParallelCreator parallelCreator; + @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); - } -} \ No newline at end of file + @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 index fc9a1b300..f4737d3b3 100644 --- a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelStatus.java +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ParallelStatus.java @@ -1,3 +1,16 @@ +/* + * 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 index d74ed48d7..db3e5e4e6 100644 --- a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchResource.java +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchResource.java @@ -1,3 +1,16 @@ +/* + * 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; @@ -11,8 +24,8 @@ /** * REST endpoint that triggers a research workflow with tool calls routed through * Dapr Workflow Activities. - *

- * Each request: + * + *

Each request: *

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

- * Example usage: + * + *

Example usage: *

  * curl "http://localhost:8080/research?country=France"
  * curl "http://localhost:8080/research?country=Germany"
@@ -30,13 +43,13 @@
 @Path("/research")
 public class ResearchResource {
 
-    @Inject
-    ResearchWriter researchWriter;
+  @Inject
+  ResearchWriter researchWriter;
 
-    @GET
-    @Produces(MediaType.TEXT_PLAIN)
-    public String research(
-            @QueryParam("country") @DefaultValue("France") String country) {
-        return researchWriter.research(country);
-    }
-}
\ No newline at end of file
+  @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
index 7ab1859f2..9af9e2918 100644
--- a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchTools.java
+++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchTools.java
@@ -1,3 +1,16 @@
+/*
+ * 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;
@@ -5,13 +18,13 @@
 
 /**
  * CDI bean providing research tools for the {@link ResearchWriter} agent.
- * 

- * Because the {@code quarkus-agentic-dapr} extension automatically applies + * + *

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: + * + *

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.
  • @@ -21,25 +34,37 @@ @ApplicationScoped public class ResearchTools { - @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."; - }; - } + /** + * 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."; + }; + } - @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."; - }; - } -} \ No newline at end of file + /** + * 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 index bbc2ed94e..aa6f2c183 100644 --- a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchWriter.java +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/ResearchWriter.java @@ -1,3 +1,16 @@ +/* + * 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; @@ -7,14 +20,14 @@ /** * 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} + * + *

    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 + * + *

    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 @@ -22,14 +35,14 @@ */ 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); -} \ No newline at end of file + @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 index 3d6a59590..26b6dd41e 100644 --- a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StoryCreator.java +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StoryCreator.java @@ -1,3 +1,16 @@ +/* + * 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; @@ -6,14 +19,14 @@ /** * Composite agent that orchestrates {@link CreativeWriter} and {@link StyleEditor} * in sequence, backed by a Dapr Workflow. - *

    - * Uses {@code @SequenceAgent} which discovers the {@code DaprWorkflowAgentsBuilder} + * + *

    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); + @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 index 03d9c1c54..afabf11f5 100644 --- a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StoryResource.java +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StoryResource.java @@ -1,3 +1,16 @@ +/* + * 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; @@ -10,23 +23,23 @@ /** * REST endpoint that triggers the sequential story creation workflow. - *

    - * Example usage: + * + *

    Example usage: *

    - * curl "http://localhost:8080/story?topic=dragons&style=comedy"
    + * curl "http://localhost:8080/story?topic=dragons&style=comedy"
      * 
    */ @Path("/story") public class StoryResource { - @Inject - StoryCreator storyCreator; + @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); - } + @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 index 811ae4409..871c88d03 100644 --- a/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StyleEditor.java +++ b/quarkus/examples/src/main/java/io/quarkiverse/dapr/examples/StyleEditor.java @@ -1,3 +1,16 @@ +/* + * 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; @@ -9,12 +22,13 @@ */ 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); + @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 index 0e623d4f5..7506658bb 100644 --- a/quarkus/examples/src/main/resources/application.properties +++ b/quarkus/examples/src/main/resources/application.properties @@ -23,4 +23,4 @@ quarkus.log.category."io.quarkiverse.dapr.workflows".level=DEBUG dapr.agents.statestore=agent-registry dapr.agents.team=default -dapr.appid=agentic-example \ No newline at end of file +dapr.appid=agentic-example diff --git a/quarkus/pom.xml b/quarkus/pom.xml index 64cfd6634..0122c9a73 100644 --- a/quarkus/pom.xml +++ b/quarkus/pom.xml @@ -41,6 +41,15 @@ pom import + + + commons-io + commons-io + 2.20.0 + 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 index b3bfe0c32..3d4a5ca0b 100644 --- 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 @@ -1,154 +1,172 @@ -package io.quarkiverse.dapr.agents.registry.model; +/* + * 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. +*/ -import java.util.List; +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("appid") + private String appId; - @JsonProperty("type") - private String type; + @JsonProperty("type") + private String type; - @JsonProperty("orchestrator") - private boolean orchestrator; + @JsonProperty("orchestrator") + private boolean orchestrator; - @JsonProperty("role") - private String role = ""; + @JsonProperty("role") + private String role = ""; - @JsonProperty("goal") - private String goal = ""; + @JsonProperty("goal") + private String goal = ""; - @JsonProperty("instructions") - private List instructions; + @JsonProperty("instructions") + private List instructions; - @JsonProperty("statestore") - private String statestore; + @JsonProperty("statestore") + private String statestore; - @JsonProperty("system_prompt") - private String systemPrompt; + @JsonProperty("system_prompt") + private String systemPrompt; - @JsonProperty("framework") - private String framework; + @JsonProperty("framework") + private String framework; - public AgentMetadata() { - } + 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; - } + 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 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 String getAppId() { - return appId; + public Builder appId(String appId) { + this.appId = appId; + return this; } - public String getType() { - return type; + public Builder type(String type) { + this.type = type; + return this; } - public boolean isOrchestrator() { - return orchestrator; + public Builder orchestrator(boolean orchestrator) { + this.orchestrator = orchestrator; + return this; } - public String getRole() { - return role; + public Builder role(String role) { + this.role = role; + return this; } - public String getGoal() { - return goal; + public Builder goal(String goal) { + this.goal = goal; + return this; } - public List getInstructions() { - return instructions; + public Builder instructions(List instructions) { + this.instructions = instructions; + return this; } - public String getStatestore() { - return statestore; + public Builder statestore(String statestore) { + this.statestore = statestore; + return this; } - public String getSystemPrompt() { - return systemPrompt; + public Builder systemPrompt(String systemPrompt) { + this.systemPrompt = systemPrompt; + return this; } - public String getFramework() { - return framework; + public Builder framework(String framework) { + this.framework = framework; + return this; } - 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; - } - - public AgentMetadata build() { - if (appId == null || type == null) { - throw new IllegalStateException("appId and type are required"); - } - return new AgentMetadata(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); } -} \ No newline at end of file + } +} 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 index 5d196e2c5..e5572b6e3 100644 --- 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 @@ -1,208 +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; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; - @JsonInclude(JsonInclude.Include.NON_NULL) public class AgentMetadataSchema { - @JsonProperty("schema_version") - private String schemaVersion; + @JsonProperty("schema_version") + private String schemaVersion; - @JsonProperty("agent") - private AgentMetadata agent; + @JsonProperty("agent") + private AgentMetadata agent; - @JsonProperty("name") - private String name; + @JsonProperty("name") + private String name; - @JsonProperty("registered_at") - private String registeredAt; + @JsonProperty("registered_at") + private String registeredAt; - @JsonProperty("pubsub") - private PubSubMetadata pubsub; + @JsonProperty("pubsub") + private PubSubMetadata pubsub; - @JsonProperty("memory") - private MemoryMetadata memory; + @JsonProperty("memory") + private MemoryMetadata memory; - @JsonProperty("llm") - private LLMMetadata llm; + @JsonProperty("llm") + private LlmMetadata llm; - @JsonProperty("registry") - private RegistryMetadata registry; + @JsonProperty("registry") + private RegistryMetadata registry; - @JsonProperty("tools") - private List tools; + @JsonProperty("tools") + private List tools; - @JsonProperty("max_iterations") - private Integer maxIterations; + @JsonProperty("max_iterations") + private Integer maxIterations; - @JsonProperty("tool_choice") - private String toolChoice; + @JsonProperty("tool_choice") + private String toolChoice; - @JsonProperty("agent_metadata") - private Map agentMetadata; + @JsonProperty("agent_metadata") + private Map agentMetadata; - public AgentMetadataSchema() { - } + 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; - } + 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 static Builder builder() { - return new Builder(); + public Builder schemaVersion(String schemaVersion) { + this.schemaVersion = schemaVersion; + return this; } - public String getSchemaVersion() { - return schemaVersion; + public Builder agent(AgentMetadata agent) { + this.agent = agent; + return this; } - public AgentMetadata getAgent() { - return agent; + public Builder name(String name) { + this.name = name; + return this; } - public String getName() { - return name; + public Builder registeredAt(String registeredAt) { + this.registeredAt = registeredAt; + return this; } - public String getRegisteredAt() { - return registeredAt; + public Builder pubsub(PubSubMetadata pubsub) { + this.pubsub = pubsub; + return this; } - public PubSubMetadata getPubsub() { - return pubsub; + public Builder memory(MemoryMetadata memory) { + this.memory = memory; + return this; } - public MemoryMetadata getMemory() { - return memory; + public Builder llm(LlmMetadata llm) { + this.llm = llm; + return this; } - public LLMMetadata getLlm() { - return llm; + public Builder registry(RegistryMetadata registry) { + this.registry = registry; + return this; } - public RegistryMetadata getRegistry() { - return registry; + public Builder tools(List tools) { + this.tools = tools; + return this; } - public List getTools() { - return tools; + /** + * 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 Integer getMaxIterations() { - return maxIterations; + public Builder maxIterations(Integer maxIterations) { + this.maxIterations = maxIterations; + return this; } - public String getToolChoice() { - return toolChoice; + public Builder toolChoice(String toolChoice) { + this.toolChoice = toolChoice; + return this; } - public Map getAgentMetadata() { - return agentMetadata; + public Builder agentMetadata(Map agentMetadata) { + this.agentMetadata = agentMetadata; + return this; } - 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; - } - - 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; - } - - 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); - } + /** + * 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); } -} \ No newline at end of file + } +} 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 index d7d28000a..d2cfdbb34 100644 --- 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 @@ -1,152 +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 { +public class LlmMetadata { - @JsonProperty("client") - private String client; + @JsonProperty("client") + private String client; - @JsonProperty("provider") - private String provider; + @JsonProperty("provider") + private String provider; - @JsonProperty("api") - private String api = "unknown"; + @JsonProperty("api") + private String api = "unknown"; - @JsonProperty("model") - private String model = "unknown"; + @JsonProperty("model") + private String model = "unknown"; - @JsonProperty("component_name") - private String componentName; + @JsonProperty("component_name") + private String componentName; - @JsonProperty("base_url") - private String baseUrl; + @JsonProperty("base_url") + private String baseUrl; - @JsonProperty("azure_endpoint") - private String azureEndpoint; + @JsonProperty("azure_endpoint") + private String azureEndpoint; - @JsonProperty("azure_deployment") - private String azureDeployment; + @JsonProperty("azure_deployment") + private String azureDeployment; - @JsonProperty("prompt_template") - private String promptTemplate; + @JsonProperty("prompt_template") + private String promptTemplate; - public LLMMetadata() { - } + 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; - } + 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 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 String getClient() { - return client; + public Builder client(String client) { + this.client = client; + return this; } - public String getProvider() { - return provider; + public Builder provider(String provider) { + this.provider = provider; + return this; } - public String getApi() { - return api; + public Builder api(String api) { + this.api = api; + return this; } - public String getModel() { - return model; + public Builder model(String model) { + this.model = model; + return this; } - public String getComponentName() { - return componentName; + public Builder componentName(String componentName) { + this.componentName = componentName; + return this; } - public String getBaseUrl() { - return baseUrl; + public Builder baseUrl(String baseUrl) { + this.baseUrl = baseUrl; + return this; } - public String getAzureEndpoint() { - return azureEndpoint; + public Builder azureEndpoint(String azureEndpoint) { + this.azureEndpoint = azureEndpoint; + return this; } - public String getAzureDeployment() { - return azureDeployment; + public Builder azureDeployment(String azureDeployment) { + this.azureDeployment = azureDeployment; + return this; } - public String getPromptTemplate() { - return promptTemplate; + public Builder promptTemplate(String promptTemplate) { + this.promptTemplate = promptTemplate; + return this; } - 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; - } - - public LLMMetadata build() { - if (client == null || provider == null) { - throw new IllegalStateException("client and provider are required"); - } - return new LLMMetadata(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 index f515d4539..57cc11031 100644 --- 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 @@ -1,54 +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("type") + private String type; - @JsonProperty("statestore") - private String statestore; + @JsonProperty("statestore") + private String statestore; - public MemoryMetadata() { - } + public MemoryMetadata() { + } - private MemoryMetadata(Builder builder) { - this.type = builder.type; - this.statestore = builder.statestore; - } + private MemoryMetadata(Builder builder) { + this.type = builder.type; + this.statestore = builder.statestore; + } - public static Builder builder() { - return new Builder(); - } + 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 String getType() { - return type; + public Builder type(String type) { + this.type = type; + return this; } - public String getStatestore() { - return statestore; + public Builder statestore(String statestore) { + this.statestore = statestore; + return this; } - 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; - } - - public MemoryMetadata build() { - if (type == null) { - throw new IllegalStateException("type is required"); - } - return new MemoryMetadata(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); } -} \ No newline at end of file + } +} 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 index c178c1f96..1a844c18b 100644 --- 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 @@ -1,68 +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("name") + private String name; - @JsonProperty("broadcast_topic") - private String broadcastTopic; + @JsonProperty("broadcast_topic") + private String broadcastTopic; - @JsonProperty("agent_topic") - private String agentTopic; + @JsonProperty("agent_topic") + private String agentTopic; - public PubSubMetadata() { - } + public PubSubMetadata() { + } - private PubSubMetadata(Builder builder) { - this.name = builder.name; - this.broadcastTopic = builder.broadcastTopic; - this.agentTopic = builder.agentTopic; - } + private PubSubMetadata(Builder builder) { + this.name = builder.name; + this.broadcastTopic = builder.broadcastTopic; + this.agentTopic = builder.agentTopic; + } - public static Builder builder() { - return new Builder(); - } + 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 String getName() { - return name; + public Builder name(String name) { + this.name = name; + return this; } - public String getBroadcastTopic() { - return broadcastTopic; + public Builder broadcastTopic(String broadcastTopic) { + this.broadcastTopic = broadcastTopic; + return this; } - public String getAgentTopic() { - return agentTopic; + public Builder agentTopic(String agentTopic) { + this.agentTopic = agentTopic; + return this; } - 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; - } - - public PubSubMetadata build() { - if (name == null) { - throw new IllegalStateException("name is required"); - } - return new PubSubMetadata(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); } -} \ No newline at end of file + } +} 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 index 373451ddb..3ca17bc54 100644 --- 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 @@ -1,51 +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("statestore") + private String statestore; - @JsonProperty("name") - private String name; + @JsonProperty("name") + private String name; - public RegistryMetadata() { - } + public RegistryMetadata() { + } - private RegistryMetadata(Builder builder) { - this.statestore = builder.statestore; - this.name = builder.name; - } + private RegistryMetadata(Builder builder) { + this.statestore = builder.statestore; + this.name = builder.name; + } - public static Builder builder() { - return new Builder(); - } + public static Builder builder() { + return new Builder(); + } - public String getStatestore() { - return statestore; - } + public String getStatestore() { + return statestore; + } - public String getName() { - return name; - } + public String getName() { + return name; + } - public static class Builder { - private String statestore; - private String name; + public static class Builder { + private String statestore; + private String name; - public Builder statestore(String statestore) { - this.statestore = statestore; - return this; - } + public Builder statestore(String statestore) { + this.statestore = statestore; + return this; + } - public Builder name(String name) { - this.name = name; - return this; - } + public Builder name(String name) { + this.name = name; + return this; + } - public RegistryMetadata build() { - return new RegistryMetadata(this); - } + public RegistryMetadata build() { + return new RegistryMetadata(this); } -} \ No newline at end of file + } +} 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 index b7ab3b96e..cca5a0a68 100644 --- 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 @@ -1,68 +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_name") + private String toolName; - @JsonProperty("tool_description") - private String toolDescription; + @JsonProperty("tool_description") + private String toolDescription; - @JsonProperty("tool_args") - private String toolArgs; + @JsonProperty("tool_args") + private String toolArgs; - public ToolMetadata() { - } + public ToolMetadata() { + } - private ToolMetadata(Builder builder) { - this.toolName = builder.toolName; - this.toolDescription = builder.toolDescription; - this.toolArgs = builder.toolArgs; - } + private ToolMetadata(Builder builder) { + this.toolName = builder.toolName; + this.toolDescription = builder.toolDescription; + this.toolArgs = builder.toolArgs; + } - public static Builder builder() { - return new Builder(); - } + 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 String getToolName() { - return toolName; + public Builder toolName(String toolName) { + this.toolName = toolName; + return this; } - public String getToolDescription() { - return toolDescription; + public Builder toolDescription(String toolDescription) { + this.toolDescription = toolDescription; + return this; } - public String getToolArgs() { - return toolArgs; + public Builder toolArgs(String toolArgs) { + this.toolArgs = toolArgs; + return this; } - 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; - } - - public ToolMetadata build() { - if (toolName == null || toolDescription == null || toolArgs == null) { - throw new IllegalStateException("toolName, toolDescription, and toolArgs are required"); - } - return new ToolMetadata(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); } -} \ No newline at end of file + } +} 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 index 2910933e0..0be11bb9c 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; @@ -28,9 +41,14 @@ public class AgentRegistry { private static final Logger LOG = Logger.getLogger(AgentRegistry.class); - /** Fully-qualified name of langchain4j {@code @Agent} annotation. */ + /** + * 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. */ + + /** + * Fully-qualified name of langchain4j {@code @SystemMessage} annotation. + */ private static final String SYSTEM_MESSAGE_ANNOTATION_NAME = "dev.langchain4j.service.SystemMessage"; @Inject @@ -57,8 +75,6 @@ void discoverAndRegisterAgents() { Set> beans = beanManager.getBeans(Object.class, Any.Literal.INSTANCE); LOG.debugf("Found %d CDI beans to scan", beans.size()); - int registered = 0; - int failed = 0; Set scannedInterfaces = new HashSet<>(); // Collect all interface classes from CDI beans first List> interfacesToScan = new ArrayList<>(); @@ -93,6 +109,8 @@ void discoverAndRegisterAgents() { 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); @@ -125,8 +143,8 @@ void discoverAndRegisterAgents() { /** * Extracts sub-agent classes from a composite agent annotation. - *

    - * Looks for a {@code subAgents()} method returning {@code Class[]} on the 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. */ @@ -148,8 +166,8 @@ static Class[] extractSubAgentClasses(Annotation ann) { /** * Scans an interface for methods annotated with {@code @Agent} and extracts metadata. - *

    - * Uses name-based annotation matching ({@code annotationType().getName()}) instead of + * + *

    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. */ @@ -213,23 +231,33 @@ private static Annotation findAnnotationByName(Method method, String annotationN return null; } - /** Invokes a no-arg method on an annotation proxy and returns the result as a String. */ + /** + * 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. */ + /** + * 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()); + 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); 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 index 5a90dad4e..6c58349a1 100644 --- 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 @@ -69,7 +69,7 @@ void buildFullSchema() { .type("conversation") .statestore("memory-store") .build()) - .llm(LLMMetadata.builder() + .llm(LlmMetadata.builder() .client("openai") .provider("openai") .api("chat") @@ -126,7 +126,7 @@ void buildFullSchema() { @Test void buildLlmWithAzureConfig() { - LLMMetadata llm = LLMMetadata.builder() + LlmMetadata llm = LlmMetadata.builder() .client("azure-openai") .provider("azure") .azureEndpoint("https://my-resource.openai.azure.com") @@ -143,7 +143,7 @@ void buildLlmWithAzureConfig() { @Test void buildLlmWithDefaults() { - LLMMetadata llm = LLMMetadata.builder() + LlmMetadata llm = LlmMetadata.builder() .client("openai") .provider("openai") .build(); @@ -170,7 +170,7 @@ void agentBuilderRequiresAppIdAndType() { @Test void llmBuilderRequiresClientAndProvider() { - assertThatThrownBy(() -> LLMMetadata.builder().build()) + assertThatThrownBy(() -> LlmMetadata.builder().build()) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("client and provider are required"); } @@ -259,7 +259,7 @@ void serializeAndDeserializeFullSchema() throws Exception { .type("buffer") .statestore("mem-store") .build()) - .llm(LLMMetadata.builder() + .llm(LlmMetadata.builder() .client("openai") .provider("openai") .api("chat") 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 index f8017f98d..0892d6c10 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; @@ -7,73 +20,99 @@ /** * Holds the synchronization state for a single agent execution. - *

    - * When a {@code @Tool}-annotated method is intercepted by {@link DaprToolCallInterceptor}, + * + *

    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. - */ - public record PendingCall( - Object target, - Method method, - Object[] args, - CompletableFuture resultFuture) { - } + /** + * Holds all the information needed for {@code ToolCallActivity} to execute the tool + * and unblock the waiting agent thread. + */ + public record PendingCall( + Object target, + Method method, + Object[] args, + CompletableFuture resultFuture) { + } - private final String agentRunId; - private final Map pendingCalls = new ConcurrentHashMap<>(); + private final String agentRunId; + private final Map pendingCalls = new ConcurrentHashMap<>(); - public AgentRunContext(String agentRunId) { - this.agentRunId = agentRunId; - } + /** + * 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; + } - public String getAgentRunId() { - return 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. - */ - 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; - } + /** + * 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. - */ - public PendingCall getPendingCall(String toolCallId) { - return pendingCalls.get(toolCallId); - } + /** + * 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}. - */ - public void completeCall(String toolCallId, Object result) { - PendingCall call = pendingCalls.remove(toolCallId); - if (call != null) { - call.resultFuture().complete(result); - } + /** + * 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. - */ - public void failCall(String toolCallId, Throwable cause) { - PendingCall call = pendingCalls.remove(toolCallId); - if (call != null) { - call.resultFuture().completeExceptionally(cause); - } + /** + * 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); } -} \ No newline at end of file + } +} 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 index 8e2ddafa0..5905dbc8a 100644 --- 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 @@ -1,8 +1,17 @@ -package io.quarkiverse.dapr.langchain4j.agent; - -import java.util.UUID; +/* + * 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. +*/ -import org.jboss.logging.Logger; +package io.quarkiverse.dapr.langchain4j.agent; import io.dapr.workflows.client.DaprWorkflowClient; import io.quarkiverse.dapr.langchain4j.agent.workflow.AgentEvent; @@ -12,110 +21,117 @@ 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

    + * + *

    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 + * + *

    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}. + * + *

    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); + private static final Logger LOG = Logger.getLogger(AgentRunLifecycleManager.class); - @Inject - DaprWorkflowClient workflowClient; + @Inject + DaprWorkflowClient workflowClient; - private String agentRunId; + 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} - */ - 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. + * + *

    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. - */ - public String getOrActivate() { - return getOrActivate(null, null, null); - } + /** + * 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 - } - } + /** + * 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(); - } + /** + * 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 index 5c4cdc8ad..d26d54c1d 100644 --- 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 @@ -1,28 +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 + * + *

    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 static final ThreadLocal AGENT_RUN_ID = new ThreadLocal<>(); - private DaprAgentContextHolder() { - } + private DaprAgentContextHolder() { + } - public static void set(String agentRunId) { - AGENT_RUN_ID.set(agentRunId); - } + /** + * 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); + } - public static String get() { - return AGENT_RUN_ID.get(); - } + /** + * 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(); + } - public static void clear() { - AGENT_RUN_ID.remove(); - } -} \ No newline at end of file + /** + * 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 index e0422d34f..127cd557c 100644 --- 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 @@ -1,20 +1,34 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + package io.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; -import jakarta.interceptor.InterceptorBinding; - /** * 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 + * + *

    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} + * 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 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 index 0509b4055..95b255a84 100644 --- 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 @@ -1,32 +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 + * + *

    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 { - public record AgentMetadata(String agentName, String userMessage, String systemMessage) { - } + /** + * 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 static final ThreadLocal METADATA = new ThreadLocal<>(); - private DaprAgentMetadataHolder() { - } + private DaprAgentMetadataHolder() { + } - public static void set(String agentName, String userMessage, String systemMessage) { - METADATA.set(new AgentMetadata(agentName, userMessage, systemMessage)); - } + /** + * 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)); + } - public static AgentMetadata get() { - return METADATA.get(); - } + /** + * 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(); + } - public static void clear() { - METADATA.remove(); - } -} \ No newline at end of file + /** + * 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 index 689e179fa..4a5502d8f 100644 --- 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 @@ -1,9 +1,17 @@ -package io.quarkiverse.dapr.langchain4j.agent; - -import java.lang.reflect.Method; -import java.util.UUID; +/* + * 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. +*/ -import org.jboss.logging.Logger; +package io.quarkiverse.dapr.langchain4j.agent; import dev.langchain4j.agentic.Agent; import dev.langchain4j.service.SystemMessage; @@ -18,22 +26,26 @@ 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} + * + *

    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 + * + *

    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 + * + *

    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. */ @@ -42,78 +54,86 @@ @Priority(Interceptor.Priority.APPLICATION) public class DaprAgentMethodInterceptor { - private static final Logger LOG = Logger.getLogger(DaprAgentMethodInterceptor.class); + private static final Logger LOG = Logger.getLogger(DaprAgentMethodInterceptor.class); - @Inject - DaprWorkflowClient workflowClient; + @Inject + DaprWorkflowClient workflowClient; - @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(); - } + /** + * 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); + // 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); + 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); + 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(); - } + 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 {@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 @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; + /** + * 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()); } -} \ No newline at end of file + 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 index 750c0ef83..f098bc3b1 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; @@ -6,31 +19,54 @@ /** * 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} + * + *

    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 static final Map REGISTRY = new ConcurrentHashMap<>(); - private DaprAgentRunRegistry() { - } + private DaprAgentRunRegistry() { + } - public static void register(String agentRunId, AgentRunContext context) { - REGISTRY.put(agentRunId, context); - } + /** + * 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); + } - public static AgentRunContext get(String agentRunId) { - return REGISTRY.get(agentRunId); - } + /** + * 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); + } - public static void unregister(String agentRunId) { - REGISTRY.remove(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); + } - public static Set getRegisteredIds() { - return REGISTRY.keySet(); - } -} \ No newline at end of file + /** + * 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 index e05d692e7..172072a91 100644 --- 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 @@ -1,18 +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; +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; -import jakarta.interceptor.InterceptorBinding; - /** * 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 + * + *

    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. */ @@ -21,4 +34,4 @@ @Retention(RetentionPolicy.RUNTIME) @Inherited public @interface DaprAgentToolInterceptorBinding { -} \ No newline at end of file +} 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 index 9b4f96f93..355d50297 100644 --- 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 @@ -1,10 +1,17 @@ -package io.quarkiverse.dapr.langchain4j.agent; +/* + * 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. +*/ -import java.lang.reflect.Method; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; - -import org.jboss.logging.Logger; +package io.quarkiverse.dapr.langchain4j.agent; import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.request.ChatRequest; @@ -19,20 +26,25 @@ 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

    + * + *

    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

    + * + *

    Execution flow

    *
      *
    1. The LangChain4j AiService calls {@code chatModel.chat(request)} which routes * through this decorator.
    2. @@ -50,208 +62,211 @@ *
    3. The result is returned to {@code LlmCallActivity}, which completes the future, * unblocking the agent thread.
    4. *
    - *

    - *

    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 * 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); + private static final Logger LOG = Logger.getLogger(DaprChatModelDecorator.class); - @Inject - @Delegate - @Any - ChatModel delegate; + @Inject + @Delegate + @Any + ChatModel delegate; - @Inject - DaprWorkflowClient workflowClient; + @Inject + DaprWorkflowClient workflowClient; - @Inject - Instance lifecycleManager; + @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); - } + /** + * 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); - } + @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(); + // 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); - } - } + 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); - } + 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 }); + // 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); + // 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); + 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)); + // 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(); + // Block the agent thread until LlmCallActivity completes the LLM execution. + return (ChatResponse) future.join(); - } catch (NoSuchMethodException e) { - LOG.warnf("[AgentRun:%s][LlmCall:%s] Could not find chat(ChatRequest) via reflection" - + " — falling back to direct call: %s", agentRunId, llmCallId, e.getMessage()); - return delegate.chat(request); - } + } 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 e) { - LOG.debugf("Could not lazily activate AgentRunWorkflow (no active request scope?): %s", - e.getMessage()); - return null; + /** + * 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 e) { - return String.valueOf(request); - } + /** + * 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 { - java.util.List messages = (java.util.List) request.getClass().getMethod("messages").invoke(request); - for (int i = messages.size() - 1; i >= 0; i--) { - Object msg = messages.get(i); - if ("UserMessage".equals(msg.getClass().getSimpleName())) { - try { - return (String) msg.getClass().getMethod("singleText").invoke(msg); - } catch (Exception e) { - return String.valueOf(msg); - } - } - } - } catch (Exception ignored) { + /** + * 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 (Exception ex) { + return String.valueOf(msg); + } } - return null; + } + } catch (Exception 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 { - java.util.List messages = (java.util.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 (Exception e) { - return String.valueOf(msg); - } - } - } - } catch (Exception ignored) { + /** + * 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 (Exception ex) { + return String.valueOf(msg); + } } - return null; + } + } catch (Exception 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 index 2747f3044..95f5d00ac 100644 --- 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 @@ -1,10 +1,17 @@ -package io.quarkiverse.dapr.langchain4j.agent; - -import java.util.Arrays; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; +/* + * 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. +*/ -import org.jboss.logging.Logger; +package io.quarkiverse.dapr.langchain4j.agent; import io.dapr.workflows.client.DaprWorkflowClient; import io.quarkiverse.dapr.langchain4j.agent.workflow.AgentEvent; @@ -14,111 +21,125 @@ 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)

    + * + *

    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})

    + * + *

    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

    + * + *

    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; - - @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(); - 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(); + 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()); } - /** - * 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; - } + 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; } -} \ No newline at end of file + } +} 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 index 5e01bea72..adfe0f24d 100644 --- 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 @@ -1,20 +1,33 @@ -package io.quarkiverse.dapr.langchain4j.agent.activities; +/* + * 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. +*/ -import io.quarkiverse.dapr.workflows.ActivityMetadata; -import org.jboss.logging.Logger; +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

    + * + *

    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. @@ -32,86 +45,90 @@ * the agent thread waiting in {@code DaprChatModelDecorator.chat()}. *
    */ + @ApplicationScoped @ActivityMetadata(name = "llm-call") public class LlmCallActivity implements WorkflowActivity { - private static final Logger LOG = Logger.getLogger(LlmCallActivity.class); + private static final Logger LOG = Logger.getLogger(LlmCallActivity.class); - @Override - public Object run(WorkflowActivityContext ctx) { - LlmCallInput input = ctx.getInput(LlmCallInput.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()); - } + 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 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()); - } + 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()); + 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 (java.lang.reflect.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(); - } + // 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 (Exception ignored) { - // Not a ChatResponse or missing expected methods — fall through. - } - return String.valueOf(result); + /** + * 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 (Exception 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 index 639beba05..706740098 100644 --- 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 @@ -1,11 +1,24 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + package io.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 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; @@ -14,4 +27,4 @@ * workflow history without needing to inspect in-process state */ public record LlmCallInput(String agentRunId, String llmCallId, String methodName, String prompt) { -} \ No newline at end of file +} 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 index b60ab4782..96897f9dc 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; /** @@ -13,4 +26,4 @@ * this is the exact text the model returned to the agent */ public record LlmCallOutput(String methodName, String prompt, String response) { -} \ No newline at end of file +} 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 index c63f73ab0..2cecd429b 100644 --- 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 @@ -1,20 +1,33 @@ -package io.quarkiverse.dapr.langchain4j.agent.activities; +/* + * 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. +*/ -import io.quarkiverse.dapr.workflows.ActivityMetadata; -import org.jboss.logging.Logger; +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

    + * + *

    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. @@ -27,60 +40,61 @@ * the agent thread waiting in {@code DaprToolCallInterceptor.intercept()}. *
    */ + @ApplicationScoped @ActivityMetadata(name = "tool-call") public class ToolCallActivity implements WorkflowActivity { - private static final Logger LOG = Logger.getLogger(ToolCallActivity.class); + private static final Logger LOG = Logger.getLogger(ToolCallActivity.class); - @Override - public Object run(WorkflowActivityContext ctx) { - ToolCallInput input = ctx.getInput(ToolCallInput.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()); + 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 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()); - } + 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()); + 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 (java.lang.reflect.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(); - } + // 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(); } -} \ No newline at end of file + } +} 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 index 9e902bc6e..9e0735d62 100644 --- 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 @@ -1,13 +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 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) { -} \ No newline at end of file +} 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 index 629b0e5f0..649bca6f2 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; /** @@ -10,4 +23,4 @@ * @param result string representation of the value returned by the tool method */ public record ToolCallOutput(String toolName, String args, String result) { -} \ No newline at end of file +} 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 index 945b65548..49e3d49bc 100644 --- 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 @@ -1,9 +1,22 @@ +/* + * 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: + * + *

    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}.
    • @@ -16,8 +29,8 @@ * @param args serialized arguments (reserved for future use; null for now) */ public record AgentEvent( - String type, - String toolCallId, - String toolName, - String args) { -} \ No newline at end of file + 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 index 9fdce087a..9e9b04313 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; /** @@ -15,4 +28,4 @@ * may be {@code null} */ public record AgentRunInput(String agentRunId, String agentName, String userMessage, String systemMessage) { -} \ No newline at end of file +} 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 index dd48456d9..f0a5183b3 100644 --- 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 @@ -1,10 +1,23 @@ -package io.quarkiverse.dapr.langchain4j.agent.workflow; +/* + * 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. +*/ -import java.util.List; +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.List; + /** * Aggregated output of a completed {@link AgentRunWorkflow}. Set as the Dapr * workflow custom status after every activity so observers can follow execution @@ -17,7 +30,7 @@ * model method name and the response text */ public record AgentRunOutput( - String agentName, - List toolCalls, - List llmCalls) { -} \ No newline at end of file + String agentName, + List toolCalls, + List 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 index 60dbdb1cf..e7c43109a 100644 --- 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 @@ -1,10 +1,17 @@ -package io.quarkiverse.dapr.langchain4j.agent.workflow; +/* + * 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. +*/ -import java.util.ArrayList; -import java.util.List; - -import io.quarkiverse.dapr.workflows.WorkflowMetadata; -import org.jboss.logging.Logger; +package io.quarkiverse.dapr.langchain4j.agent.workflow; import io.dapr.workflows.Workflow; import io.dapr.workflows.WorkflowStub; @@ -14,13 +21,17 @@ 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

      + * + *

      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} @@ -38,77 +49,78 @@ * {@link AgentRunOutput} as the custom status.
      2. *
      */ + @ApplicationScoped @WorkflowMetadata(name = "agent") public class AgentRunWorkflow implements Workflow { - private static final Logger LOG = Logger.getLogger(AgentRunWorkflow.class); + 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(); + @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)); + 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<>(); + 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(); + 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()); + 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 ("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 ("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)); - } - } + 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)); - }; - } + // 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) + "…"; + 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 index b8353e27d..9a5f81b91 100644 --- 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 @@ -1,8 +1,17 @@ -package io.quarkiverse.dapr.langchain4j.memory; +/* + * 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. +*/ -import java.util.Collections; -import java.util.List; -import java.util.function.Function; +package io.quarkiverse.dapr.langchain4j.memory; import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.ChatMessageDeserializer; @@ -11,54 +20,72 @@ 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 + * + *

      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; + private final DaprClient daprClient; + private final String stateStoreName; + private final Function, String> serializer; + private final Function> deserializer; - public KeyValueChatMemoryStore(DaprClient daprClient, String stateStoreName) { - this(daprClient, stateStoreName, - ChatMessageSerializer::messagesToJson, - ChatMessageDeserializer::messagesFromJson); - } + /** + * 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); + } - KeyValueChatMemoryStore(DaprClient daprClient, String stateStoreName, - Function, String> serializer, - Function> deserializer) { - this.daprClient = daprClient; - this.stateStoreName = stateStoreName; - this.serializer = serializer; - this.deserializer = deserializer; - } + /** + * 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 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 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(); - } -} \ No newline at end of file + @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 index dd9c37fea..c9eab1345 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; /** @@ -6,9 +19,11 @@ */ public interface DaprAgentService { - /** - * Returns the simple class name of the Dapr Workflow to schedule - * for this orchestration pattern. - */ - String workflowType(); + /** + * 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 index f89fbcd7d..2900ee51e 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; /** @@ -5,18 +18,21 @@ */ public final class DaprAgentServiceUtil { - private DaprAgentServiceUtil() { - } + private DaprAgentServiceUtil() { + } - /** - * Sanitizes a name for use as a Dapr workflow identifier. - * Replaces any non-alphanumeric characters (except hyphens and underscores) - * with underscores. - */ - public static String safeName(String name) { - if (name == null || name.isEmpty()) { - return "unnamed"; - } - return name.replaceAll("[^a-zA-Z0-9_-]", "_"); + /** + * 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 index b078ba038..dce56702b 100644 --- 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 @@ -1,8 +1,17 @@ -package io.quarkiverse.dapr.langchain4j.workflow; +/* + * 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. +*/ -import java.util.HashMap; -import java.util.Map; -import java.util.function.Predicate; +package io.quarkiverse.dapr.langchain4j.workflow; import dev.langchain4j.agentic.UntypedAgent; import dev.langchain4j.agentic.declarative.ConditionalAgent; @@ -14,6 +23,10 @@ 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} @@ -21,84 +34,122 @@ */ public class DaprConditionalAgentService extends ConditionalAgentServiceImpl implements DaprAgentService { - private final DaprWorkflowClient workflowClient; - private final Map> daprConditions = new HashMap<>(); - private int agentCounter = 0; + private final DaprWorkflowClient workflowClient; + private final Map> daprConditions = new HashMap<>(); + private int agentCounter = 0; - public DaprConditionalAgentService(Class agentServiceClass, DaprWorkflowClient workflowClient) { - super(agentServiceClass, resolveMethod(agentServiceClass)); - this.workflowClient = workflowClient; - } + /** + * 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); + private static java.lang.reflect.Method resolveMethod(Class agentServiceClass) { + if (agentServiceClass == UntypedAgent.class) { + return null; } + return AgentUtil.validateAgentClass(agentServiceClass, false, ConditionalAgent.class); + } - @Override - public String workflowType() { - return ConditionalOrchestrationWorkflow.class.getCanonicalName(); - } + /** + * {@inheritDoc} + */ + @Override + public String workflowType() { + return ConditionalOrchestrationWorkflow.class.getCanonicalName(); + } - @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(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; + } - @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 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; + } - @Override - public DaprConditionalAgentService subAgent(Predicate condition, AgentExecutor agent) { - daprConditions.put(agentCounter, condition); - agentCounter++; - super.subAgent(condition, agent); - return this; - } + /** + * {@inheritDoc} + */ + @Override + public DaprConditionalAgentService subAgent(Predicate condition, AgentExecutor agent) { + daprConditions.put(agentCounter, condition); + agentCounter++; + super.subAgent(condition, agent); + return this; + } - @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 DaprConditionalAgentService subAgent(String conditionDescription, + Predicate condition, AgentExecutor agent) { + daprConditions.put(agentCounter, condition); + agentCounter++; + super.subAgent(conditionDescription, condition, agent); + return this; + } - @Override - public T build() { - return build(() -> { - DaprWorkflowPlanner planner = new DaprWorkflowPlanner( - ConditionalOrchestrationWorkflow.class, - "Conditional", - AgenticSystemTopology.ROUTER, - workflowClient); - planner.setConditions(daprConditions); - return planner; - }); - } + /** + * {@inheritDoc} + */ + @Override + public T build() { + return build(() -> { + DaprWorkflowPlanner planner = new DaprWorkflowPlanner( + ConditionalOrchestrationWorkflow.class, + "Conditional", + AgenticSystemTopology.ROUTER, + workflowClient); + planner.setConditions(daprConditions); + return planner; + }); + } - public static DaprConditionalAgentService builder(DaprWorkflowClient workflowClient) { - return new DaprConditionalAgentService<>(UntypedAgent.class, workflowClient); - } + /** + * 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); + } - public static DaprConditionalAgentService builder(Class agentServiceClass, - DaprWorkflowClient workflowClient) { - return new DaprConditionalAgentService<>(agentServiceClass, 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 index 5c7710647..17446787e 100644 --- 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 @@ -1,7 +1,17 @@ -package io.quarkiverse.dapr.langchain4j.workflow; +/* + * 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. +*/ -import java.util.function.BiPredicate; -import java.util.function.Predicate; +package io.quarkiverse.dapr.langchain4j.workflow; import dev.langchain4j.agentic.UntypedAgent; import dev.langchain4j.agentic.declarative.LoopAgent; @@ -12,6 +22,10 @@ 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} @@ -19,91 +33,126 @@ */ public class DaprLoopAgentService extends LoopAgentServiceImpl implements DaprAgentService { - private final DaprWorkflowClient workflowClient; - private int daprMaxIterations = Integer.MAX_VALUE; - private BiPredicate daprExitCondition; - private boolean daprTestExitAtLoopEnd; - - public DaprLoopAgentService(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, 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; - } - - @Override - public DaprLoopAgentService exitCondition(String description, Predicate exitCondition) { - this.daprExitCondition = (scope, iter) -> exitCondition.test(scope); - super.exitCondition(description, exitCondition); - return this; - } - - @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; - }); - } - - public static DaprLoopAgentService builder(DaprWorkflowClient workflowClient) { - return new DaprLoopAgentService<>(UntypedAgent.class, workflowClient); - } - - public static DaprLoopAgentService builder(Class agentServiceClass, - DaprWorkflowClient workflowClient) { - return new DaprLoopAgentService<>(agentServiceClass, workflowClient); + 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 index 048962abd..3fa2d428e 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; @@ -16,40 +29,60 @@ */ public class DaprParallelAgentService extends ParallelAgentServiceImpl implements DaprAgentService { - private final DaprWorkflowClient workflowClient; + private final DaprWorkflowClient workflowClient; - public DaprParallelAgentService(Class agentServiceClass, DaprWorkflowClient workflowClient) { - super(agentServiceClass, resolveMethod(agentServiceClass)); - this.workflowClient = 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); + 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 String workflowType() { + return ParallelOrchestrationWorkflow.class.getCanonicalName(); + } - @Override - public T build() { - return build(() -> new DaprWorkflowPlanner( - ParallelOrchestrationWorkflow.class, - "Parallel", - AgenticSystemTopology.PARALLEL, - workflowClient)); - } + @Override + public T build() { + return build(() -> new DaprWorkflowPlanner( + ParallelOrchestrationWorkflow.class, + "Parallel", + AgenticSystemTopology.PARALLEL, + workflowClient)); + } - public static DaprParallelAgentService builder(DaprWorkflowClient workflowClient) { - return new DaprParallelAgentService<>(UntypedAgent.class, 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); + } - public static DaprParallelAgentService builder(Class agentServiceClass, - DaprWorkflowClient workflowClient) { - return new DaprParallelAgentService<>(agentServiceClass, 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 index a6d20b3bd..a2ac21228 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; @@ -9,21 +22,43 @@ */ public class DaprPlannerRegistry { - private static final ConcurrentHashMap registry = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap registry = new ConcurrentHashMap<>(); - public static void register(String id, DaprWorkflowPlanner planner) { - registry.put(id, planner); - } + /** + * 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); + } - public static DaprWorkflowPlanner get(String id) { - return registry.get(id); - } + /** + * 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); + } - public static void unregister(String id) { - registry.remove(id); - } + /** + * Unregisters the planner for the given ID. + * + * @param id the planner ID to unregister + */ + public static void unregister(String id) { + registry.remove(id); + } - public static String getRegisteredIds() { - return registry.keySet().toString(); - } + /** + * 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 index 701ae9c63..545c0c7da 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; @@ -16,40 +29,60 @@ */ public class DaprSequentialAgentService extends SequentialAgentServiceImpl implements DaprAgentService { - private final DaprWorkflowClient workflowClient; + private final DaprWorkflowClient workflowClient; - public DaprSequentialAgentService(Class agentServiceClass, DaprWorkflowClient workflowClient) { - super(agentServiceClass, resolveMethod(agentServiceClass)); - this.workflowClient = 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); + 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 String workflowType() { + return SequentialOrchestrationWorkflow.class.getCanonicalName(); + } - @Override - public T build() { - return build(() -> new DaprWorkflowPlanner( - SequentialOrchestrationWorkflow.class, - "Sequential", - AgenticSystemTopology.SEQUENCE, - workflowClient)); - } + @Override + public T build() { + return build(() -> new DaprWorkflowPlanner( + SequentialOrchestrationWorkflow.class, + "Sequential", + AgenticSystemTopology.SEQUENCE, + workflowClient)); + } - public static DaprSequentialAgentService builder(DaprWorkflowClient workflowClient) { - return new DaprSequentialAgentService<>(UntypedAgent.class, 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); + } - public static DaprSequentialAgentService builder(Class agentServiceClass, - DaprWorkflowClient workflowClient) { - return new DaprSequentialAgentService<>(agentServiceClass, 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 index 16ee54a17..6309bbd3e 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; @@ -13,52 +26,57 @@ * 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. + * + *

      Obtains the {@link DaprWorkflowClient} from CDI to pass to each builder. */ public class DaprWorkflowAgentsBuilder implements WorkflowAgentsBuilder { - private DaprWorkflowClient getWorkflowClient() { - return CDI.current().select(DaprWorkflowClient.class).get(); - } + /** + * 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() { + return DaprSequentialAgentService.builder(getWorkflowClient()); + } - @Override - public SequentialAgentService sequenceBuilder(Class agentServiceClass) { - return DaprSequentialAgentService.builder(agentServiceClass, getWorkflowClient()); - } + @Override + public SequentialAgentService sequenceBuilder(Class agentServiceClass) { + return DaprSequentialAgentService.builder(agentServiceClass, getWorkflowClient()); + } - @Override - public ParallelAgentService parallelBuilder() { - return DaprParallelAgentService.builder(getWorkflowClient()); - } + @Override + public ParallelAgentService parallelBuilder() { + return DaprParallelAgentService.builder(getWorkflowClient()); + } - @Override - public ParallelAgentService parallelBuilder(Class agentServiceClass) { - return DaprParallelAgentService.builder(agentServiceClass, getWorkflowClient()); - } + @Override + public ParallelAgentService parallelBuilder(Class agentServiceClass) { + return DaprParallelAgentService.builder(agentServiceClass, getWorkflowClient()); + } - @Override - public LoopAgentService loopBuilder() { - return DaprLoopAgentService.builder(getWorkflowClient()); - } + @Override + public LoopAgentService loopBuilder() { + return DaprLoopAgentService.builder(getWorkflowClient()); + } - @Override - public LoopAgentService loopBuilder(Class agentServiceClass) { - return DaprLoopAgentService.builder(agentServiceClass, getWorkflowClient()); - } + @Override + public LoopAgentService loopBuilder(Class agentServiceClass) { + return DaprLoopAgentService.builder(agentServiceClass, getWorkflowClient()); + } - @Override - public ConditionalAgentService conditionalBuilder() { - return DaprConditionalAgentService.builder(getWorkflowClient()); - } + @Override + public ConditionalAgentService conditionalBuilder() { + return DaprConditionalAgentService.builder(getWorkflowClient()); + } - @Override - public ConditionalAgentService conditionalBuilder(Class agentServiceClass) { - return DaprConditionalAgentService.builder(agentServiceClass, 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 index 4bfdfc08b..17dd3b7d2 100644 --- 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 @@ -1,20 +1,17 @@ -package io.quarkiverse.dapr.langchain4j.workflow; - -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; +/* + * 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. +*/ -import org.jboss.logging.Logger; +package io.quarkiverse.dapr.langchain4j.workflow; import dev.langchain4j.agentic.Agent; import dev.langchain4j.agentic.planner.Action; @@ -32,6 +29,21 @@ 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 @@ -40,343 +52,395 @@ */ 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. - */ - 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 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. + */ + 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); } - 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<>(); - - 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; + // 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()); + } } - @Override - public AgenticSystemTopology topology() { - return topology; + 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(); } - @Override - public void init(InitPlanningContext initPlanningContext) { - this.agents = new ArrayList<>(initPlanningContext.subagents()); - this.agenticScope = initPlanningContext.agenticScope(); - DaprPlannerRegistry.register(plannerId, this); + // 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(); } - @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); + 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(); } - - // 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 e) { - LOG.warnf("[Planner:%s] Failed to signal agent completion for agentRunId=%s: %s", - plannerId, info.agentRunId(), e.getMessage()); - } - } - - return internalNextAction(); + 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(); } - - /** - * 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 e) { - 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())); + } + + /** + * 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()); } - - // 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. - */ - 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}. - */ - public AgentMetadata getAgentMetadata(int index) { - AgentInstance agent = agents.get(index); - String agentName = agent.name(); - String userMessage = null; - String systemMessage = 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) { - userMessage = String.join("\n", userAnnotation.value()); - } - SystemMessage systemAnnotation = method.getAnnotation(SystemMessage.class); - if (systemAnnotation != null && systemAnnotation.value().length > 0) { - systemMessage = String.join("\n", systemAnnotation.value()); - } - break; - } - } + SystemMessage systemAnnotation = method.getAnnotation(SystemMessage.class); + if (systemAnnotation != null && systemAnnotation.value().length > 0) { + sm = String.join("\n", systemAnnotation.value()); } - } catch (Exception e) { - LOG.debugf("Could not extract prompt metadata from agent type for agent=%s: %s", - agentName, e.getMessage()); + break; + } } - - return new AgentMetadata(agentName, userMessage, systemMessage); - } - - /** - * Returns the agentic scope. - */ - public AgenticScope getAgenticScope() { - return agenticScope; - } - - /** - * Evaluates the exit condition for loop workflows. - */ - public boolean checkExitCondition(int iteration) { - if (exitCondition == null) { - return false; - } - return exitCondition.test(agenticScope, iteration); - } - - /** - * Evaluates whether a conditional 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); - } - - public String getPlannerId() { - return plannerId; - } - - public int getAgentCount() { - return agents.size(); - } - - // Configuration setters (called by agent service builders) - - public void setMaxIterations(int maxIterations) { - this.maxIterations = maxIterations; + } + } catch (Exception ex) { + LOG.debugf("Could not extract prompt metadata from agent type for agent=%s: %s", + agentName, ex.getMessage()); } - public void setExitCondition(BiPredicate exitCondition) { - this.exitCondition = exitCondition; + return new AgentMetadata(agentName, um, sm); + } + + /** + * Returns the agentic scope. + * + * @return the agentic scope + */ + 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; } - - public void setTestExitAtLoopEnd(boolean testExitAtLoopEnd) { - this.testExitAtLoopEnd = testExitAtLoopEnd; - } - - public void setConditions(Map> conditions) { - this.conditions = conditions; - } - - private void cleanup() { - DaprAgentContextHolder.clear(); - DaprPlannerRegistry.unregister(plannerId); + 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 } -} \ No newline at end of file + 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; + } + + 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 index e0f014c02..f9ac42bd5 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; @@ -10,17 +23,20 @@ */ public final class WorkflowNameResolver { - private WorkflowNameResolver() { - } + private WorkflowNameResolver() { + } - /** - * Returns the Dapr registration name for the given workflow class. - */ - public static String resolve(Class workflowClass) { - WorkflowMetadata meta = workflowClass.getAnnotation(WorkflowMetadata.class); - if (meta != null && !meta.name().isEmpty()) { - return meta.name(); - } - return workflowClass.getCanonicalName(); + /** + * 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 index b64ff77fc..37f78a82c 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; /** 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 index 3a2d55d3b..841086927 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; /** 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 index 0761fd773..3ecc46987 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; @@ -20,35 +33,35 @@ @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()); + @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()); + 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(); - } - }; - } -} \ No newline at end of file + 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 index d664633d8..d1ecac813 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; /** 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 index 35c447f75..b88860c5f 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; @@ -12,7 +25,8 @@ /** * 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 + * + *

      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}. */ @@ -20,53 +34,53 @@ @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()); + @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; - } - } + 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()); + // 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(); - } + 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(); - } - }; - } + // 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 index 0823a25a6..d2e754e57 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; /** 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 index a23bb7f59..d2c4c8420 100644 --- 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 @@ -1,7 +1,17 @@ -package io.quarkiverse.dapr.langchain4j.workflow.orchestration; +/* + * 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. +*/ -import java.util.ArrayList; -import java.util.List; +package io.quarkiverse.dapr.langchain4j.workflow.orchestration; import io.dapr.durabletask.Task; import io.dapr.workflows.Workflow; @@ -13,9 +23,13 @@ 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 + * + *

      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}. */ @@ -23,40 +37,40 @@ @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()); + @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()); + 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(); - } - }; - } -} \ No newline at end of file + // 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 index 5ecab8fb8..531535e61 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; @@ -19,31 +32,31 @@ @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()); + @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()); + 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(); - } - }; - } + // 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 index efd380b1a..b01e9e221 100644 --- 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 @@ -1,7 +1,17 @@ -package io.quarkiverse.dapr.langchain4j.workflow.orchestration.activities; +/* + * 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. +*/ -import io.quarkiverse.dapr.workflows.ActivityMetadata; -import org.jboss.logging.Logger; +package io.quarkiverse.dapr.langchain4j.workflow.orchestration.activities; import io.dapr.workflows.WorkflowActivity; import io.dapr.workflows.WorkflowActivityContext; @@ -11,8 +21,9 @@ 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 @@ -27,43 +38,44 @@ * signaling (sending {@code "done"} to the AgentRunWorkflow, raising an external * event to the orchestration workflow, and cleaning up the registry). * - *

      - * This activity is intentionally non-blocking to avoid exhausting the Dapr + * + *

      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); + 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()); - } + @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(); + 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); + LOG.infof("[Planner:%s] AgentExecutionActivity started — agent=%s, agentRunId=%s", + input.plannerId(), agentName, agentRunId); - AgentRunContext runContext = new AgentRunContext(agentRunId); - DaprAgentRunRegistry.register(agentRunId, runContext); + 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); + // 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); + LOG.infof("[Planner:%s] AgentExecutionActivity submitted — agent=%s, agentRunId=%s", + input.plannerId(), agentName, agentRunId); - return null; - } + 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 index 6f4e3eb97..c377839e9 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; @@ -16,13 +29,13 @@ @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()); + @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 index 61be4a7bc..7c65c3986 100644 --- 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 @@ -1,3 +1,16 @@ +/* + * 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; @@ -16,13 +29,13 @@ @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()); + @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()); + } } From 7b9851a21a9e7bbcbae06e4b114204c539e3f540 Mon Sep 17 00:00:00 2001 From: salaboy Date: Thu, 12 Mar 2026 11:35:40 +0000 Subject: [PATCH 4/6] updating checkstyle and tests paths Signed-off-by: salaboy --- .../deployment/DaprAgenticProcessor.java | 2 +- .../src/main/resources/application.properties | 6 ++--- quarkus/pom.xml | 27 +++++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) 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 index bc528d60e..689a6fd81 100644 --- 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 @@ -169,7 +169,7 @@ FeatureBuildItem feature() { */ @BuildStep IndexDependencyBuildItem indexRuntimeModule() { - return new IndexDependencyBuildItem("io.quarkiverse.dapr", "quarkus-agentic-dapr"); + return new IndexDependencyBuildItem("io.dapr.quarkus", "quarkus-agentic-dapr"); } /** diff --git a/quarkus/examples/src/main/resources/application.properties b/quarkus/examples/src/main/resources/application.properties index 7506658bb..a1c050062 100644 --- a/quarkus/examples/src/main/resources/application.properties +++ b/quarkus/examples/src/main/resources/application.properties @@ -21,6 +21,6 @@ quarkus.langchain4j.tracing.include-tool-result=true quarkus.log.category."io.quarkiverse.dapr.workflows".level=DEBUG -dapr.agents.statestore=agent-registry -dapr.agents.team=default -dapr.appid=agentic-example +#dapr.agents.statestore=agent-registry +#dapr.agents.team=default +#dapr.appid=agentic-example diff --git a/quarkus/pom.xml b/quarkus/pom.xml index 0122c9a73..c15a341e5 100644 --- a/quarkus/pom.xml +++ b/quarkus/pom.xml @@ -50,6 +50,33 @@ 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 + From d52552f3e93cbbb41c41e7d631d40f5bb0dd0020 Mon Sep 17 00:00:00 2001 From: salaboy Date: Thu, 12 Mar 2026 13:22:33 +0000 Subject: [PATCH 5/6] renaming file Signed-off-by: salaboy --- .../agents/registry/model/{LLMMetadata.java => LlmMetadata.java} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/{LLMMetadata.java => LlmMetadata.java} (100%) 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 similarity index 100% rename from quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/LLMMetadata.java rename to quarkus/quarkus-agentic-dapr-agents-registry/src/main/java/io/quarkiverse/dapr/agents/registry/model/LlmMetadata.java From 16f9092f801606fc898a981cb2cd03ffeb9f7fe4 Mon Sep 17 00:00:00 2001 From: salaboy Date: Thu, 12 Mar 2026 14:20:59 +0000 Subject: [PATCH 6/6] fixing spotbugs and adding exclusions Signed-off-by: salaboy --- .../langchain4j/agent/AgentRunContext.java | 28 +++++++++++++++++++ .../agent/DaprChatModelDecorator.java | 8 +++--- .../agent/activities/LlmCallActivity.java | 2 +- .../agent/workflow/AgentRunOutput.java | 11 ++++++++ .../workflow/DaprWorkflowPlanner.java | 4 ++- spotbugs-exclude.xml | 14 ++++++++++ 6 files changed, 61 insertions(+), 6 deletions(-) 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 index 0892d6c10..f69296be7 100644 --- 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 @@ -14,6 +14,7 @@ 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; @@ -32,11 +33,38 @@ 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; 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 index 355d50297..c9da204de 100644 --- 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 @@ -234,12 +234,12 @@ private String extractUserMessage(ChatRequest request) { if ("UserMessage".equals(msg.getClass().getSimpleName())) { try { return (String) msg.getClass().getMethod("singleText").invoke(msg); - } catch (Exception ex) { + } catch (ReflectiveOperationException ex) { return String.valueOf(msg); } } } - } catch (Exception ignored) { + } catch (ReflectiveOperationException ignored) { // intentionally empty } return null; @@ -259,12 +259,12 @@ private String extractSystemMessage(ChatRequest request) { if ("SystemMessage".equals(msg.getClass().getSimpleName())) { try { return (String) msg.getClass().getMethod("text").invoke(msg); - } catch (Exception ex) { + } catch (ReflectiveOperationException ex) { return String.valueOf(msg); } } } - } catch (Exception ignored) { + } catch (ReflectiveOperationException ignored) { // intentionally empty } 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 index adfe0f24d..816001de8 100644 --- 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 @@ -126,7 +126,7 @@ private String extractResponseText(Object result) { Object text = aiMessage.getClass().getMethod("text").invoke(aiMessage); return String.valueOf(text); } - } catch (Exception ignored) { + } 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/workflow/AgentRunOutput.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/agent/workflow/AgentRunOutput.java index f0a5183b3..75a895d88 100644 --- 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 @@ -16,6 +16,7 @@ import io.quarkiverse.dapr.langchain4j.agent.activities.LlmCallOutput; import io.quarkiverse.dapr.langchain4j.agent.activities.ToolCallOutput; +import java.util.Collections; import java.util.List; /** @@ -33,4 +34,14 @@ 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/workflow/DaprWorkflowPlanner.java b/quarkus/runtime/src/main/java/io/quarkiverse/dapr/langchain4j/workflow/DaprWorkflowPlanner.java index 17dd3b7d2..fc1b1a47f 100644 --- 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 @@ -72,6 +72,7 @@ public record AgentMetadata(String agentName, String userMessage, String systemM * 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) { } @@ -353,6 +354,7 @@ public AgentMetadata getAgentMetadata(int index) { * * @return the agentic scope */ + @SuppressWarnings("EI_EXPOSE_REP") public AgenticScope getAgenticScope() { return agenticScope; } @@ -436,7 +438,7 @@ public void setTestExitAtLoopEnd(boolean testExitAtLoopEnd) { * @param conditions the conditions map */ public void setConditions(Map> conditions) { - this.conditions = conditions; + this.conditions = conditions == null ? Collections.emptyMap() : Map.copyOf(conditions); } private void cleanup() { diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml index 98645d005..d8237ca92 100644 --- a/spotbugs-exclude.xml +++ b/spotbugs-exclude.xml @@ -63,4 +63,18 @@ + + + + + + + + + + + + + + \ No newline at end of file