From 6431d828dbce83c31ed2a53d736d3de543596c2d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 00:56:29 +0000 Subject: [PATCH 1/5] CAMEL-23954: Add moderation support to langchain4j-agent - Add AgentConfiguration.withModerationModel(ModerationModel) - Use moderated AiServices interfaces with @Moderate when configured - Wire moderationModel in AbstractAgent.configureBuilder() - Surface ModerationException details on exchange headers - Add AgentModerationTest and LangChain4jAgentModerationTest - Document content moderation in component and guardrails docs Co-authored-by: Cursor Agent --- .../catalog/components/langchain4j-agent.json | 4 +- .../docs/langchain4j-agent-component.adoc | 2 + .../docs/langchain4j-agent-guardrails.adoc | 31 +++++ .../langchain4j/agent/api/AbstractAgent.java | 8 +- .../agent/api/AgentConfiguration.java | 28 ++++ .../agent/api/AgentWithMemory.java | 37 +++-- .../agent/api/AgentWithoutMemory.java | 36 +++-- .../AiAgentWithMemoryModeratedService.java | 49 +++++++ .../AiAgentWithoutMemoryModeratedService.java | 44 ++++++ .../langchain4j/agent/api/Headers.java | 8 ++ .../agent/api/AgentConfigurationTest.java | 11 ++ .../agent/api/AgentModerationTest.java | 117 ++++++++++++++++ .../agent/api/FlaggingModerationModel.java | 40 ++++++ .../langchain4j/agent/langchain4j-agent.json | 4 +- .../docs/langchain4j-agent-component.adoc | 2 + .../docs/langchain4j-agent-guardrails.adoc | 31 +++++ .../agent/LangChain4jAgentProducer.java | 17 +++ .../agent/FlaggingModerationModel.java | 40 ++++++ .../agent/LangChain4jAgentModerationTest.java | 126 ++++++++++++++++++ 19 files changed, 610 insertions(+), 25 deletions(-) create mode 100644 components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AiAgentWithMemoryModeratedService.java create mode 100644 components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AiAgentWithoutMemoryModeratedService.java create mode 100644 components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentModerationTest.java create mode 100644 components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/FlaggingModerationModel.java create mode 100644 components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/FlaggingModerationModel.java create mode 100644 components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentModerationTest.java diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json index 82fbe66b7ed2e..6cb00f613c86a 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json @@ -53,7 +53,9 @@ "CamelLangChain4jAgentRequestModel": { "index": 10, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The request model name.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#REQUEST_MODEL" }, "CamelLangChain4jAgentResponseModel": { "index": 11, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The response model name. Not set by the agent producer when langchain4j Result does not expose it.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#RESPONSE_MODEL" }, "CamelLangChain4jAgentSources": { "index": 12, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "RAG sources retrieved during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#SOURCES" }, - "CamelLangChain4jAgentToolExecutions": { "index": 13, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Tool executions performed during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOOL_EXECUTIONS" } + "CamelLangChain4jAgentToolExecutions": { "index": 13, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Tool executions performed during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOOL_EXECUTIONS" }, + "CamelLangChain4jAgentModeration": { "index": 14, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "dev.langchain4j.model.moderation.Moderation", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "LangChain4j moderation result when input is flagged by the configured moderation model.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#MODERATION" }, + "CamelLangChain4jAgentModerationFlaggedText": { "index": 15, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Text flagged by the moderation model, when available.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#MODERATION_FLAGGED_TEXT" } }, "properties": { "agentId": { "index": 0, "kind": "path", "displayName": "Agent Id", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Agent id" }, diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc index 5d2fd309ffa91..063e81ad389ed 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc @@ -158,6 +158,7 @@ Agents are configured using the `AgentConfiguration` class which provides a flue * Chat Memory Provider (for memory-enabled agents) * Retrieval Augmentor (for RAG functionality) * Input and Output Guardrails +* Content moderation (`withModerationModel`) * Concurrent tool execution (`withExecuteToolsConcurrently`) for parallel Camel route tools and MCP tools within one LLM round trip * Tool-calling control: round-trip limits, hallucinated tool handling, and error compensation * AiServices builder customizer for advanced LangChain4j options @@ -176,6 +177,7 @@ Agents are configured using the `AgentConfiguration` class which provides a flue | `withToolArgumentsErrorHandler(...)` | Handles invalid or unparsable tool arguments | `withCompensateOnToolErrors(Boolean)` | Sends tool errors back to the LLM so it can recover instead of failing the exchange | `withExecuteToolsConcurrently()` / `withExecuteToolsConcurrently(Executor)` | Runs multiple tool calls from one LLM turn in parallel +| `withModerationModel(ModerationModel)` | Enables LangChain4j `@Moderate` checks on agent chat methods; flagged input raises `ModerationException` |=== ._Java-only: recover from a hallucinated tool name_ diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-guardrails.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-guardrails.adoc index c79d94ec21ba5..fd8354bdf4324 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-guardrails.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-guardrails.adoc @@ -610,6 +610,37 @@ public class AgentConfig { } ---- +=== Content Moderation + +LangChain4j supports provider-based content moderation through a `ModerationModel` combined with the `@Moderate` +annotation on AI service methods. The langchain4j-agent component wires moderation automatically when you configure +`AgentConfiguration.withModerationModel(...)`. Flagged user input raises `ModerationException` before the agent +response is returned. The producer sets exchange headers `CamelLangChain4jAgentModeration` and +`CamelLangChain4jAgentModerationFlaggedText` when moderation fails so routes can return a controlled refusal. + +Moderation complements guardrails: guardrails are deterministic Java rules you implement; moderation delegates policy +checks to an LLM provider API (OpenAI Moderation, Mistral moderation, etc.). + +._Java-only: public support chatbot with moderation and controlled refusal_ +[source,java] +---- +AgentConfiguration configuration = new AgentConfiguration() + .withChatModel(chatModel) + .withModerationModel(openAiModerationModel); + +context.getRegistry().bind("supportAgentConfig", configuration); + +onException(ModerationException.class) + .handled(true) + .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(422)) + .setBody(constant("Sorry, your message violates our usage policy.")); + +from("platform-http:/support/chat") + .to("langchain4j-agent:support?agentConfiguration=#supportAgentConfig&tags=orders,billing"); +---- + +In Spring Boot, inject an auto-configured `ModerationModel` bean from the LangChain4j starter alongside `ChatModel`. + === Guardrail Behavior Summary [cols="1,1,2"] diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java index 2cd440ec8f236..9555feefac400 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java @@ -41,6 +41,7 @@ *
  • Custom LangChain4j tools
  • *
  • RAG (Retrieval Augmented Generation)
  • *
  • Input and Output Guardrails
  • + *
  • Content moderation ({@code ModerationModel})
  • * * *

    @@ -126,7 +127,7 @@ public void setResponseFormat(ResponseFormat responseFormat) { * @param toolProvider the Apache Camel tool provider (may be null) */ @SuppressWarnings("unchecked") - protected void configureBuilder(AiServices builder, ToolProvider toolProvider) { + protected void configureBuilder(AiServices builder, ToolProvider toolProvider) { // Collect all tool providers to compose them into a single provider List toolProviders = new ArrayList<>(); @@ -175,6 +176,11 @@ protected void configureBuilder(AiServices builder, ToolProvider toolProvider builder.outputGuardrailClasses((List) configuration.getOutputGuardrailClasses()); } + // Content moderation (@Moderate on service interface methods) + if (configuration.getModerationModel() != null) { + builder.moderationModel(configuration.getModerationModel()); + } + // Response Format (structured output): set once at startup via setResponseFormat(), used here per request if (responseFormat != null) { builder.chatRequestTransformer(chatRequest -> chatRequest.toBuilder().responseFormat(responseFormat).build()); diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentConfiguration.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentConfiguration.java index d7f300c7f2bd0..174fc1ace439e 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentConfiguration.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentConfiguration.java @@ -33,6 +33,7 @@ import dev.langchain4j.mcp.client.McpClient; import dev.langchain4j.memory.chat.ChatMemoryProvider; import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.moderation.ModerationModel; import dev.langchain4j.rag.RetrievalAugmentor; import dev.langchain4j.service.AiServices; import dev.langchain4j.service.tool.ToolArgumentsErrorHandler; @@ -58,6 +59,7 @@ *

  • Retrieval Augmentor: For RAG (Retrieval-Augmented Generation) capabilities
  • *
  • Input Guardrails: Security filters applied to incoming messages
  • *
  • Output Guardrails: Security filters applied to agent responses
  • + *
  • Moderation Model: LangChain4j content moderation for {@code @Moderate} service methods
  • *
  • Custom Tools: Custom LangChain4j tools with @Tool annotations
  • *
  • MCP Clients: Model Context Protocol clients for external tool integration
  • *
  • MCP Tool Filters: Filters for controlling which MCP tools are available
  • @@ -69,6 +71,7 @@ public class AgentConfiguration { private static final Logger LOG = LoggerFactory.getLogger(AgentConfiguration.class); private ChatModel chatModel; + private ModerationModel moderationModel; private ChatMemoryProvider chatMemoryProvider; private RetrievalAugmentor retrievalAugmentor; private List> inputGuardrailClasses; @@ -105,6 +108,30 @@ public AgentConfiguration withChatModel(ChatModel chatModel) { return this; } + /** + * Gets the configured moderation model. + * + * @return the moderation model instance, or {@code null} if not configured + * @since 4.23 + */ + public ModerationModel getModerationModel() { + return moderationModel; + } + + /** + * Sets the LangChain4j moderation model for content policy checks on agent chat methods annotated with + * {@code @Moderate}. When configured, flagged user input raises {@code ModerationException} before the agent + * response is returned to the route. + * + * @param moderationModel the moderation model (for example OpenAI or Mistral moderation APIs) + * @return this configuration instance for method chaining + * @since 4.23 + */ + public AgentConfiguration withModerationModel(ModerationModel moderationModel) { + this.moderationModel = moderationModel; + return this; + } + /** * Gets the configured chat memory provider. * @@ -539,6 +566,7 @@ public AgentConfiguration withExecuteToolsConcurrentlyEnabled(Boolean executeToo public AgentConfiguration duplicate() { AgentConfiguration copy = new AgentConfiguration(); copy.chatModel = chatModel; + copy.moderationModel = moderationModel; copy.chatMemoryProvider = chatMemoryProvider; copy.retrievalAugmentor = retrievalAugmentor; copy.inputGuardrailClasses = inputGuardrailClasses; diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithMemory.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithMemory.java index 62a46b775f698..cc701963fea81 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithMemory.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithMemory.java @@ -36,29 +36,34 @@ public AgentWithMemory(AgentConfiguration configuration) { @Override public Result chat(AiAgentBody aiAgentBody, ToolProvider toolProvider) { - AiAgentWithMemoryService agentService = createAiAgentService(toolProvider); - String userMessage = aiAgentBody.getUserMessage(); Object memoryId = aiAgentBody.getMemoryId(); String systemMessage = aiAgentBody.getSystemMessage(); Content content = aiAgentBody.getContent(); + if (configuration.getModerationModel() != null) { + AiAgentWithMemoryModeratedService agentService = createModeratedAiAgentService(toolProvider); + if (content != null) { + return systemMessage != null + ? agentService.chat(memoryId, userMessage, content, systemMessage) + : agentService.chat(memoryId, userMessage, content); + } + return systemMessage != null + ? agentService.chat(memoryId, userMessage, systemMessage) + : agentService.chat(memoryId, userMessage); + } + + AiAgentWithMemoryService agentService = createAiAgentService(toolProvider); if (content != null) { - // Multi-modal message with content return systemMessage != null ? agentService.chat(memoryId, userMessage, content, systemMessage) : agentService.chat(memoryId, userMessage, content); - } else { - // Text-only message - return systemMessage != null - ? agentService.chat(memoryId, userMessage, systemMessage) - : agentService.chat(memoryId, userMessage); } + return systemMessage != null + ? agentService.chat(memoryId, userMessage, systemMessage) + : agentService.chat(memoryId, userMessage); } - /** - * Create AI service with memory provider and common configurations. - */ private AiAgentWithMemoryService createAiAgentService(ToolProvider toolProvider) { var builder = AiServices.builder(AiAgentWithMemoryService.class) .chatModel(configuration.getChatModel()) @@ -68,4 +73,14 @@ private AiAgentWithMemoryService createAiAgentService(ToolProvider toolProvider) return builder.build(); } + + private AiAgentWithMemoryModeratedService createModeratedAiAgentService(ToolProvider toolProvider) { + var builder = AiServices.builder(AiAgentWithMemoryModeratedService.class) + .chatModel(configuration.getChatModel()) + .chatMemoryProvider(configuration.getChatMemoryProvider()); + + configureBuilder(builder, toolProvider); + + return builder.build(); + } } diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithoutMemory.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithoutMemory.java index c2b0279cedd5d..3f3b08e0b865c 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithoutMemory.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithoutMemory.java @@ -36,28 +36,33 @@ public AgentWithoutMemory(AgentConfiguration configuration) { @Override public Result chat(AiAgentBody aiAgentBody, ToolProvider toolProvider) { - AiAgentWithoutMemoryService agentService = createAiAgentService(toolProvider); - String userMessage = aiAgentBody.getUserMessage(); String systemMessage = aiAgentBody.getSystemMessage(); Content content = aiAgentBody.getContent(); + if (configuration.getModerationModel() != null) { + AiAgentWithoutMemoryModeratedService agentService = createModeratedAiAgentService(toolProvider); + if (content != null) { + return systemMessage != null + ? agentService.chat(userMessage, content, systemMessage) + : agentService.chat(userMessage, content); + } + return systemMessage != null + ? agentService.chat(userMessage, systemMessage) + : agentService.chat(userMessage); + } + + AiAgentWithoutMemoryService agentService = createAiAgentService(toolProvider); if (content != null) { - // Multi-modal message with content return systemMessage != null ? agentService.chat(userMessage, content, systemMessage) : agentService.chat(userMessage, content); - } else { - // Text-only message - return systemMessage != null - ? agentService.chat(userMessage, systemMessage) - : agentService.chat(userMessage); } + return systemMessage != null + ? agentService.chat(userMessage, systemMessage) + : agentService.chat(userMessage); } - /** - * Create AI service with common configurations (no memory provider). - */ private AiAgentWithoutMemoryService createAiAgentService(ToolProvider toolProvider) { var builder = AiServices.builder(AiAgentWithoutMemoryService.class) .chatModel(configuration.getChatModel()); @@ -66,4 +71,13 @@ private AiAgentWithoutMemoryService createAiAgentService(ToolProvider toolProvid return builder.build(); } + + private AiAgentWithoutMemoryModeratedService createModeratedAiAgentService(ToolProvider toolProvider) { + var builder = AiServices.builder(AiAgentWithoutMemoryModeratedService.class) + .chatModel(configuration.getChatModel()); + + configureBuilder(builder, toolProvider); + + return builder.build(); + } } diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AiAgentWithMemoryModeratedService.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AiAgentWithMemoryModeratedService.java new file mode 100644 index 0000000000000..443fc21ee392d --- /dev/null +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AiAgentWithMemoryModeratedService.java @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.camel.component.langchain4j.agent.api; + +import dev.langchain4j.data.message.Content; +import dev.langchain4j.service.MemoryId; +import dev.langchain4j.service.Moderate; +import dev.langchain4j.service.Result; +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; + +/** + * Moderated variant of {@link AiAgentWithMemoryService} used when a {@code ModerationModel} is configured. + */ +interface AiAgentWithMemoryModeratedService { + + @Moderate + Result chat(@MemoryId Object memoryId, @UserMessage String message); + + @Moderate + Result chat(@MemoryId Object memoryId, @UserMessage String message, @UserMessage Content content); + + @SystemMessage("{{prompt}}") + @Moderate + Result chat( + @MemoryId Object memoryId, @UserMessage String message, + @V("prompt") String prompt); + + @SystemMessage("{{prompt}}") + @Moderate + Result chat( + @MemoryId Object memoryId, @UserMessage String message, + @UserMessage Content content, @V("prompt") String prompt); +} diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AiAgentWithoutMemoryModeratedService.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AiAgentWithoutMemoryModeratedService.java new file mode 100644 index 0000000000000..ac9f41e86a252 --- /dev/null +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AiAgentWithoutMemoryModeratedService.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.camel.component.langchain4j.agent.api; + +import dev.langchain4j.data.message.Content; +import dev.langchain4j.service.Moderate; +import dev.langchain4j.service.Result; +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; + +/** + * Moderated variant of {@link AiAgentWithoutMemoryService} used when a {@code ModerationModel} is configured. + */ +interface AiAgentWithoutMemoryModeratedService { + + @Moderate + Result chat(@UserMessage String message); + + @Moderate + Result chat(@UserMessage String message, @UserMessage Content content); + + @SystemMessage("{{prompt}}") + @Moderate + Result chat(@UserMessage String message, @V("prompt") String prompt); + + @SystemMessage("{{prompt}}") + @Moderate + Result chat(@UserMessage String message, @UserMessage Content content, @V("prompt") String prompt); +} diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java index 171a9dde6f95a..fc0f3de315d91 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java @@ -71,4 +71,12 @@ public class Headers { @Metadata(description = "Tool executions performed during agent invocation.", javaType = "java.util.List") public static final String TOOL_EXECUTIONS = "CamelLangChain4jAgentToolExecutions"; + + @Metadata(description = "LangChain4j moderation result when input is flagged by the configured moderation model.", + javaType = "dev.langchain4j.model.moderation.Moderation") + public static final String MODERATION = "CamelLangChain4jAgentModeration"; + + @Metadata(description = "Text flagged by the moderation model, when available.", + javaType = "String") + public static final String MODERATION_FLAGGED_TEXT = "CamelLangChain4jAgentModerationFlaggedText"; } diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentConfigurationTest.java b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentConfigurationTest.java index d75633725beb0..8fb7622bcac2c 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentConfigurationTest.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentConfigurationTest.java @@ -32,6 +32,7 @@ import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.moderation.ModerationModel; import dev.langchain4j.service.tool.ToolArgumentsErrorHandler; import dev.langchain4j.service.tool.ToolExecutionErrorHandler; import org.junit.jupiter.api.Test; @@ -342,6 +343,16 @@ public void testAiServicesCustomizer() { assertNotNull(config.getAiServicesCustomizer()); } + @Test + void testWithModerationModel() { + ModerationModel moderationModel = new FlaggingModerationModel("never-used"); + + AgentConfiguration config = new AgentConfiguration() + .withModerationModel(moderationModel); + + assertSame(moderationModel, config.getModerationModel()); + } + @Test void duplicateCopiesAllDeclaredInstanceFields() throws Exception { Executor executor = Executors.newSingleThreadExecutor(); diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentModerationTest.java b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentModerationTest.java new file mode 100644 index 0000000000000..4bb022186bcfe --- /dev/null +++ b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentModerationTest.java @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.camel.component.langchain4j.agent.api; + +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.memory.chat.ChatMemoryProvider; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.moderation.ModerationModel; +import dev.langchain4j.service.ModerationException; +import dev.langchain4j.service.Result; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AgentModerationTest { + + private static final String FLAGGED_TOKEN = "policy-violation"; + + @Test + void agentWithoutMemoryAllowsCleanInputWhenModerationConfigured() { + Agent agent = new AgentWithoutMemory(moderatedConfiguration()); + + Result result = agent.chat(new AiAgentBody<>("Hello support team"), null); + + assertThat(result.content()).isEqualTo("ok"); + } + + @Test + void agentWithoutMemoryRejectsFlaggedInputWhenModerationConfigured() { + Agent agent = new AgentWithoutMemory(moderatedConfiguration()); + + assertThatThrownBy(() -> agent.chat(new AiAgentBody<>("message with " + FLAGGED_TOKEN), null)) + .isInstanceOf(ModerationException.class) + .satisfies(error -> { + ModerationException moderationException = (ModerationException) error; + assertThat(moderationException.moderation()).isNotNull(); + assertThat(moderationException.moderation().flagged()).isTrue(); + assertThat(moderationException.moderation().flaggedText()) + .contains(FLAGGED_TOKEN); + }); + } + + @Test + void agentWithoutMemorySkipsModerationWhenModelNotConfigured() { + AgentConfiguration configuration = new AgentConfiguration().withChatModel(noopChatModel()); + Agent agent = new AgentWithoutMemory(configuration); + + Result result = agent.chat(new AiAgentBody<>("message with " + FLAGGED_TOKEN), null); + + assertThat(result.content()).isEqualTo("ok"); + } + + @Test + void agentWithMemoryRejectsFlaggedInputWhenModerationConfigured() { + ChatMemoryProvider memoryProvider = memoryId -> dev.langchain4j.memory.chat.MessageWindowChatMemory.builder() + .id(memoryId) + .maxMessages(10) + .build(); + + AgentConfiguration configuration = moderatedConfiguration().withChatMemoryProvider(memoryProvider); + Agent agent = new AgentWithMemory(configuration); + + assertThatThrownBy(() -> agent.chat(new AiAgentBody<>("message with " + FLAGGED_TOKEN, null, "session-1"), null)) + .isInstanceOf(ModerationException.class); + } + + @Test + void agentWithMemoryAllowsCleanInputWhenModerationConfigured() { + ChatMemoryProvider memoryProvider = memoryId -> dev.langchain4j.memory.chat.MessageWindowChatMemory.builder() + .id(memoryId) + .maxMessages(10) + .build(); + + AgentConfiguration configuration = moderatedConfiguration().withChatMemoryProvider(memoryProvider); + Agent agent = new AgentWithMemory(configuration); + + Result result = agent.chat(new AiAgentBody<>("Hello", null, "session-2"), null); + + assertThat(result.content()).isEqualTo("ok"); + } + + private static AgentConfiguration moderatedConfiguration() { + return new AgentConfiguration() + .withChatModel(noopChatModel()) + .withModerationModel(flaggingModerationModel()); + } + + private static ChatModel noopChatModel() { + return new ChatModel() { + @Override + public ChatResponse doChat(ChatRequest request) { + return ChatResponse.builder().aiMessage(AiMessage.from("ok")).build(); + } + }; + } + + private static ModerationModel flaggingModerationModel() { + return new FlaggingModerationModel(FLAGGED_TOKEN); + } +} diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/FlaggingModerationModel.java b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/FlaggingModerationModel.java new file mode 100644 index 0000000000000..e33e406151788 --- /dev/null +++ b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/FlaggingModerationModel.java @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.camel.component.langchain4j.agent.api; + +import dev.langchain4j.model.moderation.Moderation; +import dev.langchain4j.model.moderation.ModerationModel; +import dev.langchain4j.model.moderation.ModerationRequest; +import dev.langchain4j.model.moderation.ModerationResponse; + +final class FlaggingModerationModel implements ModerationModel { + + private final String flaggedToken; + + FlaggingModerationModel(String flaggedToken) { + this.flaggedToken = flaggedToken; + } + + @Override + public ModerationResponse doModerate(ModerationRequest request) { + String text = request.texts() == null || request.texts().isEmpty() ? "" : request.texts().get(0); + boolean flagged = text.contains(flaggedToken); + return ModerationResponse.builder() + .moderation(flagged ? Moderation.flagged(text) : Moderation.notFlagged()) + .build(); + } +} diff --git a/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json b/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json index 82fbe66b7ed2e..6cb00f613c86a 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json +++ b/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json @@ -53,7 +53,9 @@ "CamelLangChain4jAgentRequestModel": { "index": 10, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The request model name.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#REQUEST_MODEL" }, "CamelLangChain4jAgentResponseModel": { "index": 11, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The response model name. Not set by the agent producer when langchain4j Result does not expose it.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#RESPONSE_MODEL" }, "CamelLangChain4jAgentSources": { "index": 12, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "RAG sources retrieved during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#SOURCES" }, - "CamelLangChain4jAgentToolExecutions": { "index": 13, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Tool executions performed during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOOL_EXECUTIONS" } + "CamelLangChain4jAgentToolExecutions": { "index": 13, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Tool executions performed during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOOL_EXECUTIONS" }, + "CamelLangChain4jAgentModeration": { "index": 14, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "dev.langchain4j.model.moderation.Moderation", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "LangChain4j moderation result when input is flagged by the configured moderation model.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#MODERATION" }, + "CamelLangChain4jAgentModerationFlaggedText": { "index": 15, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Text flagged by the moderation model, when available.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#MODERATION_FLAGGED_TEXT" } }, "properties": { "agentId": { "index": 0, "kind": "path", "displayName": "Agent Id", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Agent id" }, diff --git a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc index 5d2fd309ffa91..063e81ad389ed 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc +++ b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc @@ -158,6 +158,7 @@ Agents are configured using the `AgentConfiguration` class which provides a flue * Chat Memory Provider (for memory-enabled agents) * Retrieval Augmentor (for RAG functionality) * Input and Output Guardrails +* Content moderation (`withModerationModel`) * Concurrent tool execution (`withExecuteToolsConcurrently`) for parallel Camel route tools and MCP tools within one LLM round trip * Tool-calling control: round-trip limits, hallucinated tool handling, and error compensation * AiServices builder customizer for advanced LangChain4j options @@ -176,6 +177,7 @@ Agents are configured using the `AgentConfiguration` class which provides a flue | `withToolArgumentsErrorHandler(...)` | Handles invalid or unparsable tool arguments | `withCompensateOnToolErrors(Boolean)` | Sends tool errors back to the LLM so it can recover instead of failing the exchange | `withExecuteToolsConcurrently()` / `withExecuteToolsConcurrently(Executor)` | Runs multiple tool calls from one LLM turn in parallel +| `withModerationModel(ModerationModel)` | Enables LangChain4j `@Moderate` checks on agent chat methods; flagged input raises `ModerationException` |=== ._Java-only: recover from a hallucinated tool name_ diff --git a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-guardrails.adoc b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-guardrails.adoc index c79d94ec21ba5..fd8354bdf4324 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-guardrails.adoc +++ b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-guardrails.adoc @@ -610,6 +610,37 @@ public class AgentConfig { } ---- +=== Content Moderation + +LangChain4j supports provider-based content moderation through a `ModerationModel` combined with the `@Moderate` +annotation on AI service methods. The langchain4j-agent component wires moderation automatically when you configure +`AgentConfiguration.withModerationModel(...)`. Flagged user input raises `ModerationException` before the agent +response is returned. The producer sets exchange headers `CamelLangChain4jAgentModeration` and +`CamelLangChain4jAgentModerationFlaggedText` when moderation fails so routes can return a controlled refusal. + +Moderation complements guardrails: guardrails are deterministic Java rules you implement; moderation delegates policy +checks to an LLM provider API (OpenAI Moderation, Mistral moderation, etc.). + +._Java-only: public support chatbot with moderation and controlled refusal_ +[source,java] +---- +AgentConfiguration configuration = new AgentConfiguration() + .withChatModel(chatModel) + .withModerationModel(openAiModerationModel); + +context.getRegistry().bind("supportAgentConfig", configuration); + +onException(ModerationException.class) + .handled(true) + .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(422)) + .setBody(constant("Sorry, your message violates our usage policy.")); + +from("platform-http:/support/chat") + .to("langchain4j-agent:support?agentConfiguration=#supportAgentConfig&tags=orders,billing"); +---- + +In Spring Boot, inject an auto-configured `ModerationModel` bean from the LangChain4j starter alongside `ChatModel`. + === Guardrail Behavior Summary [cols="1,1,2"] diff --git a/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java b/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java index 5dbd4b6fecc14..38a8927f17e76 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java +++ b/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java @@ -40,6 +40,7 @@ import dev.langchain4j.model.chat.request.ResponseFormatType; import dev.langchain4j.model.chat.request.json.JsonRawSchema; import dev.langchain4j.model.chat.request.json.JsonSchema; +import dev.langchain4j.service.ModerationException; import dev.langchain4j.service.Result; import dev.langchain4j.service.output.JsonSchemas; import dev.langchain4j.service.tool.ToolExecutor; @@ -198,6 +199,7 @@ public void process(Exchange exchange) throws Exception { result.finishReason(), null)); } catch (RuntimeException e) { + applyModerationHeaders(exchange, e); GenAiErrorSupport.apply(exchange, e); observation.recordError(e); throw e; @@ -206,6 +208,21 @@ public void process(Exchange exchange) throws Exception { } } + private void applyModerationHeaders(Exchange exchange, RuntimeException error) { + if (!(error instanceof ModerationException moderationException)) { + return; + } + if (moderationException.moderation() == null) { + return; + } + Message message = exchange.getMessage(); + message.setHeader(Headers.MODERATION, moderationException.moderation()); + String flaggedText = moderationException.moderation().flaggedText(); + if (flaggedText != null) { + message.setHeader(Headers.MODERATION_FLAGGED_TEXT, flaggedText); + } + } + private Object resolveChatModel(Agent agent) { if (endpoint.getConfiguration().getAgentConfiguration() != null) { return endpoint.getConfiguration().getAgentConfiguration().getChatModel(); diff --git a/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/FlaggingModerationModel.java b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/FlaggingModerationModel.java new file mode 100644 index 0000000000000..9f527e2ea247e --- /dev/null +++ b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/FlaggingModerationModel.java @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.camel.component.langchain4j.agent; + +import dev.langchain4j.model.moderation.Moderation; +import dev.langchain4j.model.moderation.ModerationModel; +import dev.langchain4j.model.moderation.ModerationRequest; +import dev.langchain4j.model.moderation.ModerationResponse; + +final class FlaggingModerationModel implements ModerationModel { + + private final String flaggedToken; + + FlaggingModerationModel(String flaggedToken) { + this.flaggedToken = flaggedToken; + } + + @Override + public ModerationResponse doModerate(ModerationRequest request) { + String text = request.texts() == null || request.texts().isEmpty() ? "" : request.texts().get(0); + boolean flagged = text.contains(flaggedToken); + return ModerationResponse.builder() + .moderation(flagged ? Moderation.flagged(text) : Moderation.notFlagged()) + .build(); + } +} diff --git a/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentModerationTest.java b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentModerationTest.java new file mode 100644 index 0000000000000..dcef5c3f49329 --- /dev/null +++ b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentModerationTest.java @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.camel.component.langchain4j.agent; + +import java.util.Properties; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +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.moderation.Moderation; +import dev.langchain4j.model.moderation.ModerationModel; +import dev.langchain4j.service.ModerationException; +import org.apache.camel.Exchange; +import org.apache.camel.RoutesBuilder; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.ai.observability.GenAiObservabilityProperties; +import org.apache.camel.component.langchain4j.agent.api.AgentConfiguration; +import org.apache.camel.component.langchain4j.agent.api.AiAgentBody; +import org.apache.camel.component.langchain4j.agent.api.Headers; +import org.apache.camel.component.mock.MockEndpoint; +import org.apache.camel.spi.Registry; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class LangChain4jAgentModerationTest extends CamelTestSupport { + + private static final String FLAGGED_TOKEN = "policy-violation"; + + private final AtomicReference moderatedExchange = new AtomicReference<>(); + + @Override + protected void bindToRegistry(Registry registry) { + registry.bind("moderatedAgentConfig", moderatedAgentConfiguration()); + } + + @Override + protected RoutesBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + Properties properties = new Properties(); + properties.setProperty(GenAiObservabilityProperties.ENABLED, "false"); + context.getPropertiesComponent().setOverrideProperties(properties); + + onException(ModerationException.class) + .process(exchange -> moderatedExchange.set(exchange)) + .setBody(constant("Sorry, your message violates our usage policy.")) + .handled(true); + + from("direct:moderated") + .to("langchain4j-agent:support?agentConfiguration=#moderatedAgentConfig") + .to("mock:result"); + + from("direct:clean") + .to("langchain4j-agent:support?agentConfiguration=#moderatedAgentConfig") + .to("mock:success"); + } + }; + } + + @Test + void shouldExposeModerationHeadersWhenInputIsFlagged() throws Exception { + MockEndpoint mock = getMockEndpoint("mock:result"); + mock.expectedMessageCount(0); + + template.sendBody("direct:moderated", new AiAgentBody<>("contains " + FLAGGED_TOKEN)); + + mock.assertIsSatisfied(10, TimeUnit.SECONDS); + + Exchange exchange = moderatedExchange.get(); + assertThat(exchange).isNotNull(); + assertThat(exchange.getMessage().getBody(String.class)) + .isEqualTo("Sorry, your message violates our usage policy."); + assertThat(exchange.getMessage().getHeader(Headers.MODERATION, Moderation.class)) + .isNotNull() + .extracting(Moderation::flagged) + .isEqualTo(true); + assertThat(exchange.getMessage().getHeader(Headers.MODERATION_FLAGGED_TEXT, String.class)) + .contains(FLAGGED_TOKEN); + } + + @Test + void shouldReturnResponseWhenInputPassesModeration() throws Exception { + MockEndpoint mock = getMockEndpoint("mock:success"); + mock.expectedMessageCount(1); + mock.expectedBodiesReceived("ok"); + + template.sendBody("direct:clean", new AiAgentBody<>("Hello support")); + + mock.assertIsSatisfied(10, TimeUnit.SECONDS); + } + + private static AgentConfiguration moderatedAgentConfiguration() { + ChatModel chatModel = new ChatModel() { + @Override + public ChatResponse doChat(ChatRequest request) { + return ChatResponse.builder().aiMessage(AiMessage.from("ok")).build(); + } + }; + + ModerationModel moderationModel = new FlaggingModerationModel(FLAGGED_TOKEN); + + return new AgentConfiguration() + .withChatModel(chatModel) + .withModerationModel(moderationModel); + } +} From eabb6f962554b718af0746f90fbfd081ac315017 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 01:06:52 +0000 Subject: [PATCH 2/5] CAMEL-23954: Pre-moderate langchain4j-agent user input before chat Replace duplicated @Moderate service interfaces with Camel-owned pre-moderation via ModerationSupport so flagged input is rejected before the chat model, tools, or memory are updated. - Add AgentConfiguration.withModerationModel(ModerationModel) - Set CamelLangChain4jAgentModerationFlagged header on failure - Classify ModerationException as GenAiErrorCategory.VALIDATION - Expand API and component moderation tests - Document pre-moderation semantics in component and guardrails docs Co-authored-by: Cursor --- .../catalog/components/langchain4j-agent.json | 3 +- .../docs/langchain4j-agent-component.adoc | 23 +++++- .../docs/langchain4j-agent-guardrails.adoc | 13 ++-- .../camel-ai-observability-api/pom.xml | 6 ++ .../ai/observability/GenAiErrorSupport.java | 3 +- .../observability/GenAiErrorSupportTest.java | 7 ++ .../langchain4j/agent/api/AbstractAgent.java | 5 -- .../agent/api/AgentConfiguration.java | 6 +- .../agent/api/AgentWithMemory.java | 24 +----- .../agent/api/AgentWithoutMemory.java | 23 +----- .../AiAgentWithMemoryModeratedService.java | 49 ------------ .../AiAgentWithoutMemoryModeratedService.java | 44 ----------- .../langchain4j/agent/api/Headers.java | 10 +-- .../agent/api/ModerationSupport.java | 66 ++++++++++++++++ .../agent/api/AgentModerationTest.java | 75 ++++++++++++++++--- .../langchain4j/agent/langchain4j-agent.json | 3 +- .../docs/langchain4j-agent-component.adoc | 23 +++++- .../docs/langchain4j-agent-guardrails.adoc | 13 ++-- .../agent/LangChain4jAgentProducer.java | 9 +-- .../agent/LangChain4jAgentModerationTest.java | 64 +++++++++++++--- 20 files changed, 269 insertions(+), 200 deletions(-) delete mode 100644 components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AiAgentWithMemoryModeratedService.java delete mode 100644 components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AiAgentWithoutMemoryModeratedService.java create mode 100644 components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/ModerationSupport.java diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json index 6cb00f613c86a..2f95c3d71ea3a 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json @@ -54,8 +54,7 @@ "CamelLangChain4jAgentResponseModel": { "index": 11, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The response model name. Not set by the agent producer when langchain4j Result does not expose it.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#RESPONSE_MODEL" }, "CamelLangChain4jAgentSources": { "index": 12, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "RAG sources retrieved during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#SOURCES" }, "CamelLangChain4jAgentToolExecutions": { "index": 13, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Tool executions performed during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOOL_EXECUTIONS" }, - "CamelLangChain4jAgentModeration": { "index": 14, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "dev.langchain4j.model.moderation.Moderation", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "LangChain4j moderation result when input is flagged by the configured moderation model.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#MODERATION" }, - "CamelLangChain4jAgentModerationFlaggedText": { "index": 15, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Text flagged by the moderation model, when available.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#MODERATION_FLAGGED_TEXT" } + "CamelLangChain4jAgentModerationFlagged": { "index": 14, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Whether the user message was flagged by the configured moderation model.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#MODERATION_FLAGGED" } }, "properties": { "agentId": { "index": 0, "kind": "path", "displayName": "Agent Id", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Agent id" }, diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc index 063e81ad389ed..64462e9ecb40c 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc @@ -177,9 +177,30 @@ Agents are configured using the `AgentConfiguration` class which provides a flue | `withToolArgumentsErrorHandler(...)` | Handles invalid or unparsable tool arguments | `withCompensateOnToolErrors(Boolean)` | Sends tool errors back to the LLM so it can recover instead of failing the exchange | `withExecuteToolsConcurrently()` / `withExecuteToolsConcurrently(Executor)` | Runs multiple tool calls from one LLM turn in parallel -| `withModerationModel(ModerationModel)` | Enables LangChain4j `@Moderate` checks on agent chat methods; flagged input raises `ModerationException` |=== +==== Content moderation + +When `AgentConfiguration.withModerationModel(ModerationModel)` is set, Camel pre-moderates the user message before invoking the chat model. Flagged input raises `ModerationException` before tools run or memory is updated. The producer sets the `CamelLangChain4jAgentModerationFlagged` header to `true` on failure so routes can return a controlled refusal. See xref:langchain4j-agent-guardrails.adoc#_content_moderation[Content Moderation] for route examples. + +._Java-only: public support chatbot with moderation_ +[source,java] +---- +AgentConfiguration configuration = new AgentConfiguration() + .withChatModel(chatModel) + .withModerationModel(openAiModerationModel); + +context.getRegistry().bind("supportAgentConfig", configuration); + +onException(ModerationException.class) + .handled(true) + .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(422)) + .setBody(constant("Sorry, your message violates our usage policy.")); + +from("platform-http:/support/chat") + .to("langchain4j-agent:support?agentConfiguration=#supportAgentConfig&tags=orders,billing"); +---- + ._Java-only: recover from a hallucinated tool name_ [source,java] ---- diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-guardrails.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-guardrails.adoc index fd8354bdf4324..065d5a96e955f 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-guardrails.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-guardrails.adoc @@ -612,14 +612,11 @@ public class AgentConfig { === Content Moderation -LangChain4j supports provider-based content moderation through a `ModerationModel` combined with the `@Moderate` -annotation on AI service methods. The langchain4j-agent component wires moderation automatically when you configure -`AgentConfiguration.withModerationModel(...)`. Flagged user input raises `ModerationException` before the agent -response is returned. The producer sets exchange headers `CamelLangChain4jAgentModeration` and -`CamelLangChain4jAgentModerationFlaggedText` when moderation fails so routes can return a controlled refusal. - -Moderation complements guardrails: guardrails are deterministic Java rules you implement; moderation delegates policy -checks to an LLM provider API (OpenAI Moderation, Mistral moderation, etc.). +Configure a LangChain4j `ModerationModel` on `AgentConfiguration.withModerationModel(...)`. Camel pre-moderates the user message before the chat model is invoked, so flagged input raises `ModerationException` before tools run or conversation memory is updated. The producer sets the `CamelLangChain4jAgentModerationFlagged` header to `true` when moderation fails so routes can return a controlled refusal. + +Moderation complements guardrails: guardrails are deterministic Java rules you implement; moderation delegates policy checks to a provider API (OpenAI Moderation, Mistral moderation, etc.). Only the user message text is moderated; system prompts configured by the route author are not sent to the moderation model. + +Custom `Agent` implementations must call moderation themselves if they bypass `AgentWithMemory` / `AgentWithoutMemory`. ._Java-only: public support chatbot with moderation and controlled refusal_ [source,java] diff --git a/components/camel-ai/camel-ai-observability-api/pom.xml b/components/camel-ai/camel-ai-observability-api/pom.xml index b61b987baa867..7c10e07355a4c 100644 --- a/components/camel-ai/camel-ai-observability-api/pom.xml +++ b/components/camel-ai/camel-ai-observability-api/pom.xml @@ -49,6 +49,12 @@ ${langchain4j-version} test + + dev.langchain4j + langchain4j + ${langchain4j-version} + test + org.apache.camel diff --git a/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiErrorSupport.java b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiErrorSupport.java index dc7ce3ccc68de..d7eab30ff7d5b 100644 --- a/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiErrorSupport.java +++ b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiErrorSupport.java @@ -48,7 +48,8 @@ public final class GenAiErrorSupport { Map.entry("dev.langchain4j.exception.InvalidRequestException", GenAiErrorCategory.VALIDATION), Map.entry("dev.langchain4j.exception.ContentFilteredException", GenAiErrorCategory.VALIDATION), Map.entry("dev.langchain4j.exception.ModelNotFoundException", GenAiErrorCategory.VALIDATION), - Map.entry("dev.langchain4j.exception.ToolArgumentsException", GenAiErrorCategory.VALIDATION)); + Map.entry("dev.langchain4j.exception.ToolArgumentsException", GenAiErrorCategory.VALIDATION), + Map.entry("dev.langchain4j.service.ModerationException", GenAiErrorCategory.VALIDATION)); private static final Map OPENAI_EXCEPTION_CATEGORIES = Map.ofEntries( Map.entry("com.openai.errors.RateLimitException", GenAiErrorCategory.RATE_LIMIT), diff --git a/components/camel-ai/camel-ai-observability-api/src/test/java/org/apache/camel/component/ai/observability/GenAiErrorSupportTest.java b/components/camel-ai/camel-ai-observability-api/src/test/java/org/apache/camel/component/ai/observability/GenAiErrorSupportTest.java index 1ce0b4ad46bad..18671ff6219be 100644 --- a/components/camel-ai/camel-ai-observability-api/src/test/java/org/apache/camel/component/ai/observability/GenAiErrorSupportTest.java +++ b/components/camel-ai/camel-ai-observability-api/src/test/java/org/apache/camel/component/ai/observability/GenAiErrorSupportTest.java @@ -98,6 +98,13 @@ void shouldApplyCategoryPropertyToExchange() { assertThat(exchange.getProperty(GenAiErrorProperties.RETRY_AFTER_MILLIS)).isNull(); } + @Test + void shouldClassifyModerationExceptionAsValidation() { + dev.langchain4j.model.moderation.Moderation moderation = dev.langchain4j.model.moderation.Moderation.flagged("bad"); + assertThat(GenAiErrorSupport.classify(new dev.langchain4j.service.ModerationException("flagged", moderation))) + .isEqualTo(GenAiErrorCategory.VALIDATION); + } + @Test void shouldReturnUnknownForUnrecognizedException() { assertThat(GenAiErrorSupport.classify(new IllegalStateException("boom"))) diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java index 9555feefac400..7fc22608a3e3d 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java @@ -176,11 +176,6 @@ protected void configureBuilder(AiServices builder, ToolProvider toolProvider builder.outputGuardrailClasses((List) configuration.getOutputGuardrailClasses()); } - // Content moderation (@Moderate on service interface methods) - if (configuration.getModerationModel() != null) { - builder.moderationModel(configuration.getModerationModel()); - } - // Response Format (structured output): set once at startup via setResponseFormat(), used here per request if (responseFormat != null) { builder.chatRequestTransformer(chatRequest -> chatRequest.toBuilder().responseFormat(responseFormat).build()); diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentConfiguration.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentConfiguration.java index 174fc1ace439e..fbbb7129c2b56 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentConfiguration.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentConfiguration.java @@ -119,9 +119,9 @@ public ModerationModel getModerationModel() { } /** - * Sets the LangChain4j moderation model for content policy checks on agent chat methods annotated with - * {@code @Moderate}. When configured, flagged user input raises {@code ModerationException} before the agent - * response is returned to the route. + * Sets the LangChain4j moderation model for content policy checks on agent user messages. When configured, flagged + * user input raises {@code ModerationException} before the chat model is invoked, so tools and memory are not + * updated for rejected input. * * @param moderationModel the moderation model (for example OpenAI or Mistral moderation APIs) * @return this configuration instance for method chaining diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithMemory.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithMemory.java index cc701963fea81..7e9e068ff7680 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithMemory.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithMemory.java @@ -36,23 +36,13 @@ public AgentWithMemory(AgentConfiguration configuration) { @Override public Result chat(AiAgentBody aiAgentBody, ToolProvider toolProvider) { + ModerationSupport.moderateUserMessage(configuration.getModerationModel(), aiAgentBody.getUserMessage()); + String userMessage = aiAgentBody.getUserMessage(); Object memoryId = aiAgentBody.getMemoryId(); String systemMessage = aiAgentBody.getSystemMessage(); Content content = aiAgentBody.getContent(); - if (configuration.getModerationModel() != null) { - AiAgentWithMemoryModeratedService agentService = createModeratedAiAgentService(toolProvider); - if (content != null) { - return systemMessage != null - ? agentService.chat(memoryId, userMessage, content, systemMessage) - : agentService.chat(memoryId, userMessage, content); - } - return systemMessage != null - ? agentService.chat(memoryId, userMessage, systemMessage) - : agentService.chat(memoryId, userMessage); - } - AiAgentWithMemoryService agentService = createAiAgentService(toolProvider); if (content != null) { return systemMessage != null @@ -73,14 +63,4 @@ private AiAgentWithMemoryService createAiAgentService(ToolProvider toolProvider) return builder.build(); } - - private AiAgentWithMemoryModeratedService createModeratedAiAgentService(ToolProvider toolProvider) { - var builder = AiServices.builder(AiAgentWithMemoryModeratedService.class) - .chatModel(configuration.getChatModel()) - .chatMemoryProvider(configuration.getChatMemoryProvider()); - - configureBuilder(builder, toolProvider); - - return builder.build(); - } } diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithoutMemory.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithoutMemory.java index 3f3b08e0b865c..027786d9455e0 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithoutMemory.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithoutMemory.java @@ -36,22 +36,12 @@ public AgentWithoutMemory(AgentConfiguration configuration) { @Override public Result chat(AiAgentBody aiAgentBody, ToolProvider toolProvider) { + ModerationSupport.moderateUserMessage(configuration.getModerationModel(), aiAgentBody.getUserMessage()); + String userMessage = aiAgentBody.getUserMessage(); String systemMessage = aiAgentBody.getSystemMessage(); Content content = aiAgentBody.getContent(); - if (configuration.getModerationModel() != null) { - AiAgentWithoutMemoryModeratedService agentService = createModeratedAiAgentService(toolProvider); - if (content != null) { - return systemMessage != null - ? agentService.chat(userMessage, content, systemMessage) - : agentService.chat(userMessage, content); - } - return systemMessage != null - ? agentService.chat(userMessage, systemMessage) - : agentService.chat(userMessage); - } - AiAgentWithoutMemoryService agentService = createAiAgentService(toolProvider); if (content != null) { return systemMessage != null @@ -71,13 +61,4 @@ private AiAgentWithoutMemoryService createAiAgentService(ToolProvider toolProvid return builder.build(); } - - private AiAgentWithoutMemoryModeratedService createModeratedAiAgentService(ToolProvider toolProvider) { - var builder = AiServices.builder(AiAgentWithoutMemoryModeratedService.class) - .chatModel(configuration.getChatModel()); - - configureBuilder(builder, toolProvider); - - return builder.build(); - } } diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AiAgentWithMemoryModeratedService.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AiAgentWithMemoryModeratedService.java deleted file mode 100644 index 443fc21ee392d..0000000000000 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AiAgentWithMemoryModeratedService.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You 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 org.apache.camel.component.langchain4j.agent.api; - -import dev.langchain4j.data.message.Content; -import dev.langchain4j.service.MemoryId; -import dev.langchain4j.service.Moderate; -import dev.langchain4j.service.Result; -import dev.langchain4j.service.SystemMessage; -import dev.langchain4j.service.UserMessage; -import dev.langchain4j.service.V; - -/** - * Moderated variant of {@link AiAgentWithMemoryService} used when a {@code ModerationModel} is configured. - */ -interface AiAgentWithMemoryModeratedService { - - @Moderate - Result chat(@MemoryId Object memoryId, @UserMessage String message); - - @Moderate - Result chat(@MemoryId Object memoryId, @UserMessage String message, @UserMessage Content content); - - @SystemMessage("{{prompt}}") - @Moderate - Result chat( - @MemoryId Object memoryId, @UserMessage String message, - @V("prompt") String prompt); - - @SystemMessage("{{prompt}}") - @Moderate - Result chat( - @MemoryId Object memoryId, @UserMessage String message, - @UserMessage Content content, @V("prompt") String prompt); -} diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AiAgentWithoutMemoryModeratedService.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AiAgentWithoutMemoryModeratedService.java deleted file mode 100644 index ac9f41e86a252..0000000000000 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AiAgentWithoutMemoryModeratedService.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You 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 org.apache.camel.component.langchain4j.agent.api; - -import dev.langchain4j.data.message.Content; -import dev.langchain4j.service.Moderate; -import dev.langchain4j.service.Result; -import dev.langchain4j.service.SystemMessage; -import dev.langchain4j.service.UserMessage; -import dev.langchain4j.service.V; - -/** - * Moderated variant of {@link AiAgentWithoutMemoryService} used when a {@code ModerationModel} is configured. - */ -interface AiAgentWithoutMemoryModeratedService { - - @Moderate - Result chat(@UserMessage String message); - - @Moderate - Result chat(@UserMessage String message, @UserMessage Content content); - - @SystemMessage("{{prompt}}") - @Moderate - Result chat(@UserMessage String message, @V("prompt") String prompt); - - @SystemMessage("{{prompt}}") - @Moderate - Result chat(@UserMessage String message, @UserMessage Content content, @V("prompt") String prompt); -} diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java index fc0f3de315d91..0823db4998e92 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java @@ -72,11 +72,7 @@ public class Headers { javaType = "java.util.List") public static final String TOOL_EXECUTIONS = "CamelLangChain4jAgentToolExecutions"; - @Metadata(description = "LangChain4j moderation result when input is flagged by the configured moderation model.", - javaType = "dev.langchain4j.model.moderation.Moderation") - public static final String MODERATION = "CamelLangChain4jAgentModeration"; - - @Metadata(description = "Text flagged by the moderation model, when available.", - javaType = "String") - public static final String MODERATION_FLAGGED_TEXT = "CamelLangChain4jAgentModerationFlaggedText"; + @Metadata(description = "Whether the user message was flagged by the configured moderation model.", + javaType = "Boolean") + public static final String MODERATION_FLAGGED = "CamelLangChain4jAgentModerationFlagged"; } diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/ModerationSupport.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/ModerationSupport.java new file mode 100644 index 0000000000000..df63f651a4c62 --- /dev/null +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/ModerationSupport.java @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.camel.component.langchain4j.agent.api; + +import java.util.List; + +import dev.langchain4j.model.moderation.Moderation; +import dev.langchain4j.model.moderation.ModerationModel; +import dev.langchain4j.model.moderation.ModerationRequest; +import dev.langchain4j.model.moderation.ModerationResponse; +import dev.langchain4j.service.ModerationException; +import org.apache.camel.util.ObjectHelper; + +/** + * Pre-invocation content moderation for agent chat requests. + *

    + * Camel runs moderation before building the LangChain4j AI service call so flagged user input is rejected before the + * chat model, tools, or memory are updated. + * + * @since 4.23 + */ +final class ModerationSupport { + + private ModerationSupport() { + } + + /** + * Moderates the user message when a {@link ModerationModel} is configured. + * + * @param moderationModel the moderation model, may be {@code null} + * @param userMessage the user message to check + * @throws ModerationException when the moderation model flags the input + */ + static void moderateUserMessage(ModerationModel moderationModel, String userMessage) { + if (moderationModel == null || ObjectHelper.isEmpty(userMessage)) { + return; + } + + ModerationRequest request = ModerationRequest.builder() + .texts(List.of(userMessage)) + .build(); + ModerationResponse response = moderationModel.doModerate(request); + if (response == null || response.moderation() == null) { + return; + } + + Moderation moderation = response.moderation(); + if (moderation.flagged()) { + throw new ModerationException("User message flagged by moderation model", moderation); + } + } +} diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentModerationTest.java b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentModerationTest.java index 4bb022186bcfe..fd6ae0b01f8db 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentModerationTest.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentModerationTest.java @@ -16,11 +16,14 @@ */ package org.apache.camel.component.langchain4j.agent.api; +import java.util.concurrent.atomic.AtomicInteger; + import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.memory.chat.ChatMemoryProvider; import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.moderation.Moderation; import dev.langchain4j.model.moderation.ModerationModel; import dev.langchain4j.service.ModerationException; import dev.langchain4j.service.Result; @@ -35,7 +38,7 @@ class AgentModerationTest { @Test void agentWithoutMemoryAllowsCleanInputWhenModerationConfigured() { - Agent agent = new AgentWithoutMemory(moderatedConfiguration()); + Agent agent = new AgentWithoutMemory(moderatedConfiguration(countingChatModel(new AtomicInteger()))); Result result = agent.chat(new AiAgentBody<>("Hello support team"), null); @@ -44,7 +47,7 @@ void agentWithoutMemoryAllowsCleanInputWhenModerationConfigured() { @Test void agentWithoutMemoryRejectsFlaggedInputWhenModerationConfigured() { - Agent agent = new AgentWithoutMemory(moderatedConfiguration()); + Agent agent = new AgentWithoutMemory(moderatedConfiguration(countingChatModel(new AtomicInteger()))); assertThatThrownBy(() -> agent.chat(new AiAgentBody<>("message with " + FLAGGED_TOKEN), null)) .isInstanceOf(ModerationException.class) @@ -52,11 +55,20 @@ void agentWithoutMemoryRejectsFlaggedInputWhenModerationConfigured() { ModerationException moderationException = (ModerationException) error; assertThat(moderationException.moderation()).isNotNull(); assertThat(moderationException.moderation().flagged()).isTrue(); - assertThat(moderationException.moderation().flaggedText()) - .contains(FLAGGED_TOKEN); }); } + @Test + void agentWithoutMemoryDoesNotInvokeChatModelWhenInputIsFlagged() { + AtomicInteger chatInvocations = new AtomicInteger(); + Agent agent = new AgentWithoutMemory(moderatedConfiguration(countingChatModel(chatInvocations))); + + assertThatThrownBy(() -> agent.chat(new AiAgentBody<>("message with " + FLAGGED_TOKEN), null)) + .isInstanceOf(ModerationException.class); + + assertThat(chatInvocations.get()).isZero(); + } + @Test void agentWithoutMemorySkipsModerationWhenModelNotConfigured() { AgentConfiguration configuration = new AgentConfiguration().withChatModel(noopChatModel()); @@ -74,11 +86,30 @@ void agentWithMemoryRejectsFlaggedInputWhenModerationConfigured() { .maxMessages(10) .build(); - AgentConfiguration configuration = moderatedConfiguration().withChatMemoryProvider(memoryProvider); + AgentConfiguration configuration = moderatedConfiguration(countingChatModel(new AtomicInteger())) + .withChatMemoryProvider(memoryProvider); + Agent agent = new AgentWithMemory(configuration); + + assertThatThrownBy(() -> agent.chat(new AiAgentBody<>("message with " + FLAGGED_TOKEN, null, "session-1"), null)) + .isInstanceOf(ModerationException.class); + } + + @Test + void agentWithMemoryDoesNotInvokeChatModelWhenInputIsFlagged() { + AtomicInteger chatInvocations = new AtomicInteger(); + ChatMemoryProvider memoryProvider = memoryId -> dev.langchain4j.memory.chat.MessageWindowChatMemory.builder() + .id(memoryId) + .maxMessages(10) + .build(); + + AgentConfiguration configuration = moderatedConfiguration(countingChatModel(chatInvocations)) + .withChatMemoryProvider(memoryProvider); Agent agent = new AgentWithMemory(configuration); assertThatThrownBy(() -> agent.chat(new AiAgentBody<>("message with " + FLAGGED_TOKEN, null, "session-1"), null)) .isInstanceOf(ModerationException.class); + + assertThat(chatInvocations.get()).isZero(); } @Test @@ -88,7 +119,8 @@ void agentWithMemoryAllowsCleanInputWhenModerationConfigured() { .maxMessages(10) .build(); - AgentConfiguration configuration = moderatedConfiguration().withChatMemoryProvider(memoryProvider); + AgentConfiguration configuration = moderatedConfiguration(countingChatModel(new AtomicInteger())) + .withChatMemoryProvider(memoryProvider); Agent agent = new AgentWithMemory(configuration); Result result = agent.chat(new AiAgentBody<>("Hello", null, "session-2"), null); @@ -96,21 +128,46 @@ void agentWithMemoryAllowsCleanInputWhenModerationConfigured() { assertThat(result.content()).isEqualTo("ok"); } - private static AgentConfiguration moderatedConfiguration() { + @Test + void moderationSupportSkipsEmptyUserMessage() { + ModerationModel moderationModel = new FlaggingModerationModel(FLAGGED_TOKEN); + + ModerationSupport.moderateUserMessage(moderationModel, null); + ModerationSupport.moderateUserMessage(moderationModel, ""); + } + + @Test + void moderationSupportThrowsWhenModelFlagsInput() { + ModerationModel moderationModel = new FlaggingModerationModel(FLAGGED_TOKEN); + + assertThatThrownBy(() -> ModerationSupport.moderateUserMessage(moderationModel, "contains " + FLAGGED_TOKEN)) + .isInstanceOf(ModerationException.class) + .extracting(error -> ((ModerationException) error).moderation()) + .isNotNull() + .extracting(Moderation::flagged) + .isEqualTo(true); + } + + private static AgentConfiguration moderatedConfiguration(ChatModel chatModel) { return new AgentConfiguration() - .withChatModel(noopChatModel()) + .withChatModel(chatModel) .withModerationModel(flaggingModerationModel()); } - private static ChatModel noopChatModel() { + private static ChatModel countingChatModel(AtomicInteger counter) { return new ChatModel() { @Override public ChatResponse doChat(ChatRequest request) { + counter.incrementAndGet(); return ChatResponse.builder().aiMessage(AiMessage.from("ok")).build(); } }; } + private static ChatModel noopChatModel() { + return countingChatModel(new AtomicInteger()); + } + private static ModerationModel flaggingModerationModel() { return new FlaggingModerationModel(FLAGGED_TOKEN); } diff --git a/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json b/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json index 6cb00f613c86a..2f95c3d71ea3a 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json +++ b/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json @@ -54,8 +54,7 @@ "CamelLangChain4jAgentResponseModel": { "index": 11, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The response model name. Not set by the agent producer when langchain4j Result does not expose it.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#RESPONSE_MODEL" }, "CamelLangChain4jAgentSources": { "index": 12, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "RAG sources retrieved during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#SOURCES" }, "CamelLangChain4jAgentToolExecutions": { "index": 13, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Tool executions performed during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOOL_EXECUTIONS" }, - "CamelLangChain4jAgentModeration": { "index": 14, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "dev.langchain4j.model.moderation.Moderation", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "LangChain4j moderation result when input is flagged by the configured moderation model.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#MODERATION" }, - "CamelLangChain4jAgentModerationFlaggedText": { "index": 15, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Text flagged by the moderation model, when available.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#MODERATION_FLAGGED_TEXT" } + "CamelLangChain4jAgentModerationFlagged": { "index": 14, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Whether the user message was flagged by the configured moderation model.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#MODERATION_FLAGGED" } }, "properties": { "agentId": { "index": 0, "kind": "path", "displayName": "Agent Id", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Agent id" }, diff --git a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc index 063e81ad389ed..64462e9ecb40c 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc +++ b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc @@ -177,9 +177,30 @@ Agents are configured using the `AgentConfiguration` class which provides a flue | `withToolArgumentsErrorHandler(...)` | Handles invalid or unparsable tool arguments | `withCompensateOnToolErrors(Boolean)` | Sends tool errors back to the LLM so it can recover instead of failing the exchange | `withExecuteToolsConcurrently()` / `withExecuteToolsConcurrently(Executor)` | Runs multiple tool calls from one LLM turn in parallel -| `withModerationModel(ModerationModel)` | Enables LangChain4j `@Moderate` checks on agent chat methods; flagged input raises `ModerationException` |=== +==== Content moderation + +When `AgentConfiguration.withModerationModel(ModerationModel)` is set, Camel pre-moderates the user message before invoking the chat model. Flagged input raises `ModerationException` before tools run or memory is updated. The producer sets the `CamelLangChain4jAgentModerationFlagged` header to `true` on failure so routes can return a controlled refusal. See xref:langchain4j-agent-guardrails.adoc#_content_moderation[Content Moderation] for route examples. + +._Java-only: public support chatbot with moderation_ +[source,java] +---- +AgentConfiguration configuration = new AgentConfiguration() + .withChatModel(chatModel) + .withModerationModel(openAiModerationModel); + +context.getRegistry().bind("supportAgentConfig", configuration); + +onException(ModerationException.class) + .handled(true) + .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(422)) + .setBody(constant("Sorry, your message violates our usage policy.")); + +from("platform-http:/support/chat") + .to("langchain4j-agent:support?agentConfiguration=#supportAgentConfig&tags=orders,billing"); +---- + ._Java-only: recover from a hallucinated tool name_ [source,java] ---- diff --git a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-guardrails.adoc b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-guardrails.adoc index fd8354bdf4324..065d5a96e955f 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-guardrails.adoc +++ b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-guardrails.adoc @@ -612,14 +612,11 @@ public class AgentConfig { === Content Moderation -LangChain4j supports provider-based content moderation through a `ModerationModel` combined with the `@Moderate` -annotation on AI service methods. The langchain4j-agent component wires moderation automatically when you configure -`AgentConfiguration.withModerationModel(...)`. Flagged user input raises `ModerationException` before the agent -response is returned. The producer sets exchange headers `CamelLangChain4jAgentModeration` and -`CamelLangChain4jAgentModerationFlaggedText` when moderation fails so routes can return a controlled refusal. - -Moderation complements guardrails: guardrails are deterministic Java rules you implement; moderation delegates policy -checks to an LLM provider API (OpenAI Moderation, Mistral moderation, etc.). +Configure a LangChain4j `ModerationModel` on `AgentConfiguration.withModerationModel(...)`. Camel pre-moderates the user message before the chat model is invoked, so flagged input raises `ModerationException` before tools run or conversation memory is updated. The producer sets the `CamelLangChain4jAgentModerationFlagged` header to `true` when moderation fails so routes can return a controlled refusal. + +Moderation complements guardrails: guardrails are deterministic Java rules you implement; moderation delegates policy checks to a provider API (OpenAI Moderation, Mistral moderation, etc.). Only the user message text is moderated; system prompts configured by the route author are not sent to the moderation model. + +Custom `Agent` implementations must call moderation themselves if they bypass `AgentWithMemory` / `AgentWithoutMemory`. ._Java-only: public support chatbot with moderation and controlled refusal_ [source,java] diff --git a/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java b/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java index 38a8927f17e76..cad78ee9ffa2b 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java +++ b/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java @@ -212,15 +212,10 @@ private void applyModerationHeaders(Exchange exchange, RuntimeException error) { if (!(error instanceof ModerationException moderationException)) { return; } - if (moderationException.moderation() == null) { + if (moderationException.moderation() == null || !moderationException.moderation().flagged()) { return; } - Message message = exchange.getMessage(); - message.setHeader(Headers.MODERATION, moderationException.moderation()); - String flaggedText = moderationException.moderation().flaggedText(); - if (flaggedText != null) { - message.setHeader(Headers.MODERATION_FLAGGED_TEXT, flaggedText); - } + exchange.getMessage().setHeader(Headers.MODERATION_FLAGGED, Boolean.TRUE); } private Object resolveChatModel(Agent agent) { diff --git a/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentModerationTest.java b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentModerationTest.java index dcef5c3f49329..761f083885393 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentModerationTest.java +++ b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentModerationTest.java @@ -18,18 +18,21 @@ import java.util.Properties; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.memory.chat.ChatMemoryProvider; import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.response.ChatResponse; -import dev.langchain4j.model.moderation.Moderation; import dev.langchain4j.model.moderation.ModerationModel; import dev.langchain4j.service.ModerationException; import org.apache.camel.Exchange; import org.apache.camel.RoutesBuilder; import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.ai.observability.GenAiErrorCategory; +import org.apache.camel.component.ai.observability.GenAiErrorProperties; import org.apache.camel.component.ai.observability.GenAiObservabilityProperties; import org.apache.camel.component.langchain4j.agent.api.AgentConfiguration; import org.apache.camel.component.langchain4j.agent.api.AiAgentBody; @@ -46,10 +49,12 @@ class LangChain4jAgentModerationTest extends CamelTestSupport { private static final String FLAGGED_TOKEN = "policy-violation"; private final AtomicReference moderatedExchange = new AtomicReference<>(); + private final AtomicInteger chatInvocations = new AtomicInteger(); @Override protected void bindToRegistry(Registry registry) { - registry.bind("moderatedAgentConfig", moderatedAgentConfiguration()); + registry.bind("moderatedAgentConfig", moderatedAgentConfiguration(chatInvocations)); + registry.bind("memoryModeratedAgentConfig", memoryModeratedAgentConfiguration(new AtomicInteger())); } @Override @@ -73,12 +78,16 @@ public void configure() { from("direct:clean") .to("langchain4j-agent:support?agentConfiguration=#moderatedAgentConfig") .to("mock:success"); + + from("direct:memory-moderated") + .to("langchain4j-agent:support?agentConfiguration=#memoryModeratedAgentConfig") + .to("mock:memory-result"); } }; } @Test - void shouldExposeModerationHeadersWhenInputIsFlagged() throws Exception { + void shouldExposeModerationFlaggedHeaderWhenInputIsFlagged() throws Exception { MockEndpoint mock = getMockEndpoint("mock:result"); mock.expectedMessageCount(0); @@ -90,12 +99,22 @@ void shouldExposeModerationHeadersWhenInputIsFlagged() throws Exception { assertThat(exchange).isNotNull(); assertThat(exchange.getMessage().getBody(String.class)) .isEqualTo("Sorry, your message violates our usage policy."); - assertThat(exchange.getMessage().getHeader(Headers.MODERATION, Moderation.class)) - .isNotNull() - .extracting(Moderation::flagged) - .isEqualTo(true); - assertThat(exchange.getMessage().getHeader(Headers.MODERATION_FLAGGED_TEXT, String.class)) - .contains(FLAGGED_TOKEN); + assertThat(exchange.getMessage().getHeader(Headers.MODERATION_FLAGGED, Boolean.class)).isTrue(); + assertThat(exchange.getProperty(GenAiErrorProperties.ERROR_CATEGORY, String.class)) + .isEqualTo(GenAiErrorCategory.VALIDATION.name()); + } + + @Test + void shouldNotInvokeChatModelWhenInputIsFlagged() throws Exception { + chatInvocations.set(0); + MockEndpoint mock = getMockEndpoint("mock:result"); + mock.expectedMessageCount(0); + + template.sendBody("direct:moderated", new AiAgentBody<>("contains " + FLAGGED_TOKEN)); + + mock.assertIsSatisfied(10, TimeUnit.SECONDS); + + assertThat(chatInvocations.get()).isZero(); } @Test @@ -109,10 +128,26 @@ void shouldReturnResponseWhenInputPassesModeration() throws Exception { mock.assertIsSatisfied(10, TimeUnit.SECONDS); } - private static AgentConfiguration moderatedAgentConfiguration() { + @Test + void shouldRejectFlaggedInputForMemoryAgentRoute() throws Exception { + MockEndpoint mock = getMockEndpoint("mock:memory-result"); + mock.expectedMessageCount(0); + + template.sendBody("direct:memory-moderated", + new AiAgentBody<>("contains " + FLAGGED_TOKEN, null, "session-42")); + + mock.assertIsSatisfied(10, TimeUnit.SECONDS); + + Exchange exchange = moderatedExchange.get(); + assertThat(exchange).isNotNull(); + assertThat(exchange.getMessage().getHeader(Headers.MODERATION_FLAGGED, Boolean.class)).isTrue(); + } + + private static AgentConfiguration moderatedAgentConfiguration(AtomicInteger chatInvocations) { ChatModel chatModel = new ChatModel() { @Override public ChatResponse doChat(ChatRequest request) { + chatInvocations.incrementAndGet(); return ChatResponse.builder().aiMessage(AiMessage.from("ok")).build(); } }; @@ -123,4 +158,13 @@ public ChatResponse doChat(ChatRequest request) { .withChatModel(chatModel) .withModerationModel(moderationModel); } + + private static AgentConfiguration memoryModeratedAgentConfiguration(AtomicInteger chatInvocations) { + ChatMemoryProvider memoryProvider = memoryId -> dev.langchain4j.memory.chat.MessageWindowChatMemory.builder() + .id(memoryId) + .maxMessages(10) + .build(); + + return moderatedAgentConfiguration(chatInvocations).withChatMemoryProvider(memoryProvider); + } } From 0557c8d8d6bbe47ee8f050a03abeabd7d26daf3c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 01:23:44 +0000 Subject: [PATCH 3/5] CAMEL-23954: Address review feedback on moderation gate - Fail closed when moderation returns no verdict or throws - Use ModerationModel.moderate(String) public API with error wrapping - Make ModerationSupport public for custom Agent implementations - Walk cause chain when setting MODERATION_FLAGGED header - Fix guardrails xref module prefix and refresh header metadata - Expand tests for null verdict, memory provider skip, GenAI errors Co-authored-by: Cursor --- .../catalog/components/langchain4j-agent.json | 2 +- .../docs/langchain4j-agent-component.adoc | 2 +- .../docs/langchain4j-agent-guardrails.adoc | 2 +- .../observability/GenAiErrorSupportTest.java | 22 ++++++++- .../langchain4j/agent/api/AbstractAgent.java | 1 - .../agent/api/AgentConfiguration.java | 2 +- .../langchain4j/agent/api/Headers.java | 2 +- .../agent/api/ModerationSupport.java | 46 +++++++++++-------- .../agent/api/AgentConfigurationTest.java | 1 + .../agent/api/AgentModerationTest.java | 40 ++++++++++++++++ .../langchain4j/agent/langchain4j-agent.json | 2 +- .../docs/langchain4j-agent-component.adoc | 2 +- .../docs/langchain4j-agent-guardrails.adoc | 2 +- .../agent/LangChain4jAgentProducer.java | 15 +++--- .../agent/LangChain4jAgentModerationTest.java | 8 +++- .../org/apache/camel/model.properties | 1 + 16 files changed, 114 insertions(+), 36 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json index 2f95c3d71ea3a..bd4b5b9e9ad7b 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json @@ -54,7 +54,7 @@ "CamelLangChain4jAgentResponseModel": { "index": 11, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The response model name. Not set by the agent producer when langchain4j Result does not expose it.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#RESPONSE_MODEL" }, "CamelLangChain4jAgentSources": { "index": 12, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "RAG sources retrieved during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#SOURCES" }, "CamelLangChain4jAgentToolExecutions": { "index": 13, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Tool executions performed during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOOL_EXECUTIONS" }, - "CamelLangChain4jAgentModerationFlagged": { "index": 14, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Whether the user message was flagged by the configured moderation model.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#MODERATION_FLAGGED" } + "CamelLangChain4jAgentModerationFlagged": { "index": 14, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Set to true when user input is rejected by the configured moderation model; not set on success.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#MODERATION_FLAGGED" } }, "properties": { "agentId": { "index": 0, "kind": "path", "displayName": "Agent Id", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Agent id" }, diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc index 64462e9ecb40c..858d1efea1ca1 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc @@ -181,7 +181,7 @@ Agents are configured using the `AgentConfiguration` class which provides a flue ==== Content moderation -When `AgentConfiguration.withModerationModel(ModerationModel)` is set, Camel pre-moderates the user message before invoking the chat model. Flagged input raises `ModerationException` before tools run or memory is updated. The producer sets the `CamelLangChain4jAgentModerationFlagged` header to `true` on failure so routes can return a controlled refusal. See xref:langchain4j-agent-guardrails.adoc#_content_moderation[Content Moderation] for route examples. +When `AgentConfiguration.withModerationModel(ModerationModel)` is set, Camel pre-moderates the user message before invoking the chat model. Flagged input raises `ModerationException` before tools run or memory is updated. The producer sets the `CamelLangChain4jAgentModerationFlagged` header to `true` on failure so routes can return a controlled refusal. See xref:others:langchain4j-agent-guardrails.adoc#_content_moderation[Content Moderation] for route examples. Category details are available on `${exception.moderation}` when handling `ModerationException`. ._Java-only: public support chatbot with moderation_ [source,java] diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-guardrails.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-guardrails.adoc index 065d5a96e955f..f88798a195e35 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-guardrails.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-guardrails.adoc @@ -616,7 +616,7 @@ Configure a LangChain4j `ModerationModel` on `AgentConfiguration.withModerationM Moderation complements guardrails: guardrails are deterministic Java rules you implement; moderation delegates policy checks to a provider API (OpenAI Moderation, Mistral moderation, etc.). Only the user message text is moderated; system prompts configured by the route author are not sent to the moderation model. -Custom `Agent` implementations must call moderation themselves if they bypass `AgentWithMemory` / `AgentWithoutMemory`. +Custom `Agent` implementations should call `ModerationSupport.moderateUserMessage(...)` when they bypass `AgentWithMemory` / `AgentWithoutMemory`. Moderation failures are policy rejections (`GenAiErrorCategory.VALIDATION`), not malformed requests to retry blindly. ._Java-only: public support chatbot with moderation and controlled refusal_ [source,java] diff --git a/components/camel-ai/camel-ai-observability-api/src/test/java/org/apache/camel/component/ai/observability/GenAiErrorSupportTest.java b/components/camel-ai/camel-ai-observability-api/src/test/java/org/apache/camel/component/ai/observability/GenAiErrorSupportTest.java index 18671ff6219be..b44074a68eaf7 100644 --- a/components/camel-ai/camel-ai-observability-api/src/test/java/org/apache/camel/component/ai/observability/GenAiErrorSupportTest.java +++ b/components/camel-ai/camel-ai-observability-api/src/test/java/org/apache/camel/component/ai/observability/GenAiErrorSupportTest.java @@ -21,6 +21,8 @@ import dev.langchain4j.exception.InternalServerException; import dev.langchain4j.exception.InvalidRequestException; import dev.langchain4j.exception.RateLimitException; +import dev.langchain4j.model.moderation.Moderation; +import dev.langchain4j.service.ModerationException; import org.apache.camel.support.DefaultExchange; import org.apache.camel.test.junit6.CamelTestSupport; import org.junit.jupiter.api.Test; @@ -100,11 +102,27 @@ void shouldApplyCategoryPropertyToExchange() { @Test void shouldClassifyModerationExceptionAsValidation() { - dev.langchain4j.model.moderation.Moderation moderation = dev.langchain4j.model.moderation.Moderation.flagged("bad"); - assertThat(GenAiErrorSupport.classify(new dev.langchain4j.service.ModerationException("flagged", moderation))) + Moderation moderation = Moderation.flagged("bad"); + assertThat(GenAiErrorSupport.classify(new ModerationException("flagged", moderation))) .isEqualTo(GenAiErrorCategory.VALIDATION); } + @Test + void shouldClassifyWrappedModerationExceptionAsValidation() { + Moderation moderation = Moderation.flagged("bad"); + RuntimeException wrapped = new RuntimeException("outer", new ModerationException("flagged", moderation)); + assertThat(GenAiErrorSupport.classify(wrapped)).isEqualTo(GenAiErrorCategory.VALIDATION); + } + + @Test + void shouldApplyModerationExceptionCategoryToExchange() { + DefaultExchange exchange = new DefaultExchange(context); + GenAiErrorSupport.apply(exchange, new ModerationException("flagged", Moderation.flagged("bad"))); + + assertThat(exchange.getProperty(GenAiErrorProperties.ERROR_CATEGORY, String.class)) + .isEqualTo(GenAiErrorCategory.VALIDATION.name()); + } + @Test void shouldReturnUnknownForUnrecognizedException() { assertThat(GenAiErrorSupport.classify(new IllegalStateException("boom"))) diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java index 7fc22608a3e3d..dc52e042ea334 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java @@ -41,7 +41,6 @@ *

  • Custom LangChain4j tools
  • *
  • RAG (Retrieval Augmented Generation)
  • *
  • Input and Output Guardrails
  • - *
  • Content moderation ({@code ModerationModel})
  • * * *

    diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentConfiguration.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentConfiguration.java index fbbb7129c2b56..d45a09af72a1d 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentConfiguration.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentConfiguration.java @@ -59,7 +59,7 @@ *

  • Retrieval Augmentor: For RAG (Retrieval-Augmented Generation) capabilities
  • *
  • Input Guardrails: Security filters applied to incoming messages
  • *
  • Output Guardrails: Security filters applied to agent responses
  • - *
  • Moderation Model: LangChain4j content moderation for {@code @Moderate} service methods
  • + *
  • Moderation Model: Pre-moderation of user message text before the chat model is invoked
  • *
  • Custom Tools: Custom LangChain4j tools with @Tool annotations
  • *
  • MCP Clients: Model Context Protocol clients for external tool integration
  • *
  • MCP Tool Filters: Filters for controlling which MCP tools are available
  • diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java index 0823db4998e92..067fbad76124c 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java @@ -72,7 +72,7 @@ public class Headers { javaType = "java.util.List") public static final String TOOL_EXECUTIONS = "CamelLangChain4jAgentToolExecutions"; - @Metadata(description = "Whether the user message was flagged by the configured moderation model.", + @Metadata(description = "Set to true when user input is rejected by the configured moderation model; not set on success.", javaType = "Boolean") public static final String MODERATION_FLAGGED = "CamelLangChain4jAgentModerationFlagged"; } diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/ModerationSupport.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/ModerationSupport.java index df63f651a4c62..81cf3c7226026 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/ModerationSupport.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/ModerationSupport.java @@ -16,12 +16,9 @@ */ package org.apache.camel.component.langchain4j.agent.api; -import java.util.List; - import dev.langchain4j.model.moderation.Moderation; import dev.langchain4j.model.moderation.ModerationModel; -import dev.langchain4j.model.moderation.ModerationRequest; -import dev.langchain4j.model.moderation.ModerationResponse; +import dev.langchain4j.model.output.Response; import dev.langchain4j.service.ModerationException; import org.apache.camel.util.ObjectHelper; @@ -29,38 +26,51 @@ * Pre-invocation content moderation for agent chat requests. *

    * Camel runs moderation before building the LangChain4j AI service call so flagged user input is rejected before the - * chat model, tools, or memory are updated. + * chat model, tools, or memory are updated. Custom {@link Agent} implementations should call + * {@link #moderateUserMessage(ModerationModel, String)} with the same semantics when they bypass + * {@link AgentWithMemory} or {@link AgentWithoutMemory}. * * @since 4.23 */ -final class ModerationSupport { +public final class ModerationSupport { private ModerationSupport() { } /** * Moderates the user message when a {@link ModerationModel} is configured. + *

    + * Only non-empty user message text is moderated. Multimodal {@code Content} without a user message is not sent to + * the moderation model. When a moderation model is configured, a missing verdict fails closed and raises + * {@link ModerationException}. * * @param moderationModel the moderation model, may be {@code null} * @param userMessage the user message to check - * @throws ModerationException when the moderation model flags the input + * @throws ModerationException when the moderation model flags the input or returns no verdict */ - static void moderateUserMessage(ModerationModel moderationModel, String userMessage) { + public static void moderateUserMessage(ModerationModel moderationModel, String userMessage) { if (moderationModel == null || ObjectHelper.isEmpty(userMessage)) { return; } - ModerationRequest request = ModerationRequest.builder() - .texts(List.of(userMessage)) - .build(); - ModerationResponse response = moderationModel.doModerate(request); - if (response == null || response.moderation() == null) { - return; - } + try { + Response response = moderationModel.moderate(userMessage); + if (response == null || response.content() == null) { + throw new ModerationException( + "Moderation model returned no verdict for user message", + Moderation.flagged(userMessage)); + } - Moderation moderation = response.moderation(); - if (moderation.flagged()) { - throw new ModerationException("User message flagged by moderation model", moderation); + Moderation moderation = response.content(); + if (moderation.flagged()) { + throw new ModerationException("User message flagged by moderation model", moderation); + } + } catch (ModerationException e) { + throw e; + } catch (RuntimeException e) { + throw new ModerationException( + "Moderation model failed to evaluate user message: " + e.getMessage(), + Moderation.flagged(userMessage)); } } } diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentConfigurationTest.java b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentConfigurationTest.java index 8fb7622bcac2c..3981e9625c93d 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentConfigurationTest.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentConfigurationTest.java @@ -370,6 +370,7 @@ public ChatResponse doChat(ChatRequest request) { AgentConfiguration original = new AgentConfiguration() .withChatModel(chatModel) + .withModerationModel(new FlaggingModerationModel("never-used")) .withMaxToolCallingRoundTrips(11) .withHallucinatedToolNameStrategy(strategy) .withToolExecutionErrorHandler(execHandler) diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentModerationTest.java b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentModerationTest.java index fd6ae0b01f8db..76d1a67ec39db 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentModerationTest.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentModerationTest.java @@ -128,6 +128,46 @@ void agentWithMemoryAllowsCleanInputWhenModerationConfigured() { assertThat(result.content()).isEqualTo("ok"); } + @Test + void moderationSupportFailsClosedWhenModelReturnsNoVerdict() { + ModerationModel moderationModel = new ModerationModel() { + @Override + public dev.langchain4j.model.moderation.ModerationResponse doModerate( + dev.langchain4j.model.moderation.ModerationRequest request) { + return null; + } + }; + + assertThatThrownBy(() -> ModerationSupport.moderateUserMessage(moderationModel, "hello")) + .isInstanceOf(ModerationException.class) + .satisfies(error -> { + ModerationException moderationException = (ModerationException) error; + assertThat(moderationException.moderation()).isNotNull(); + assertThat(moderationException.moderation().flagged()).isTrue(); + }); + } + + @Test + void agentWithMemoryDoesNotAccessMemoryProviderWhenInputIsFlagged() { + AtomicInteger memoryProviderInvocations = new AtomicInteger(); + ChatMemoryProvider memoryProvider = memoryId -> { + memoryProviderInvocations.incrementAndGet(); + return dev.langchain4j.memory.chat.MessageWindowChatMemory.builder() + .id(memoryId) + .maxMessages(10) + .build(); + }; + + AgentConfiguration configuration = moderatedConfiguration(countingChatModel(new AtomicInteger())) + .withChatMemoryProvider(memoryProvider); + Agent agent = new AgentWithMemory(configuration); + + assertThatThrownBy(() -> agent.chat(new AiAgentBody<>("message with " + FLAGGED_TOKEN, null, "session-1"), null)) + .isInstanceOf(ModerationException.class); + + assertThat(memoryProviderInvocations.get()).isZero(); + } + @Test void moderationSupportSkipsEmptyUserMessage() { ModerationModel moderationModel = new FlaggingModerationModel(FLAGGED_TOKEN); diff --git a/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json b/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json index 2f95c3d71ea3a..bd4b5b9e9ad7b 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json +++ b/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json @@ -54,7 +54,7 @@ "CamelLangChain4jAgentResponseModel": { "index": 11, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The response model name. Not set by the agent producer when langchain4j Result does not expose it.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#RESPONSE_MODEL" }, "CamelLangChain4jAgentSources": { "index": 12, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "RAG sources retrieved during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#SOURCES" }, "CamelLangChain4jAgentToolExecutions": { "index": 13, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Tool executions performed during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOOL_EXECUTIONS" }, - "CamelLangChain4jAgentModerationFlagged": { "index": 14, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Whether the user message was flagged by the configured moderation model.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#MODERATION_FLAGGED" } + "CamelLangChain4jAgentModerationFlagged": { "index": 14, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Set to true when user input is rejected by the configured moderation model; not set on success.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#MODERATION_FLAGGED" } }, "properties": { "agentId": { "index": 0, "kind": "path", "displayName": "Agent Id", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Agent id" }, diff --git a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc index 64462e9ecb40c..858d1efea1ca1 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc +++ b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc @@ -181,7 +181,7 @@ Agents are configured using the `AgentConfiguration` class which provides a flue ==== Content moderation -When `AgentConfiguration.withModerationModel(ModerationModel)` is set, Camel pre-moderates the user message before invoking the chat model. Flagged input raises `ModerationException` before tools run or memory is updated. The producer sets the `CamelLangChain4jAgentModerationFlagged` header to `true` on failure so routes can return a controlled refusal. See xref:langchain4j-agent-guardrails.adoc#_content_moderation[Content Moderation] for route examples. +When `AgentConfiguration.withModerationModel(ModerationModel)` is set, Camel pre-moderates the user message before invoking the chat model. Flagged input raises `ModerationException` before tools run or memory is updated. The producer sets the `CamelLangChain4jAgentModerationFlagged` header to `true` on failure so routes can return a controlled refusal. See xref:others:langchain4j-agent-guardrails.adoc#_content_moderation[Content Moderation] for route examples. Category details are available on `${exception.moderation}` when handling `ModerationException`. ._Java-only: public support chatbot with moderation_ [source,java] diff --git a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-guardrails.adoc b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-guardrails.adoc index 065d5a96e955f..f88798a195e35 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-guardrails.adoc +++ b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-guardrails.adoc @@ -616,7 +616,7 @@ Configure a LangChain4j `ModerationModel` on `AgentConfiguration.withModerationM Moderation complements guardrails: guardrails are deterministic Java rules you implement; moderation delegates policy checks to a provider API (OpenAI Moderation, Mistral moderation, etc.). Only the user message text is moderated; system prompts configured by the route author are not sent to the moderation model. -Custom `Agent` implementations must call moderation themselves if they bypass `AgentWithMemory` / `AgentWithoutMemory`. +Custom `Agent` implementations should call `ModerationSupport.moderateUserMessage(...)` when they bypass `AgentWithMemory` / `AgentWithoutMemory`. Moderation failures are policy rejections (`GenAiErrorCategory.VALIDATION`), not malformed requests to retry blindly. ._Java-only: public support chatbot with moderation and controlled refusal_ [source,java] diff --git a/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java b/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java index cad78ee9ffa2b..d82c6da90bb7b 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java +++ b/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java @@ -209,13 +209,16 @@ public void process(Exchange exchange) throws Exception { } private void applyModerationHeaders(Exchange exchange, RuntimeException error) { - if (!(error instanceof ModerationException moderationException)) { - return; - } - if (moderationException.moderation() == null || !moderationException.moderation().flagged()) { - return; + Throwable current = error; + while (current != null) { + if (current instanceof ModerationException moderationException) { + if (moderationException.moderation() != null && moderationException.moderation().flagged()) { + exchange.getMessage().setHeader(Headers.MODERATION_FLAGGED, Boolean.TRUE); + } + return; + } + current = current.getCause(); } - exchange.getMessage().setHeader(Headers.MODERATION_FLAGGED, Boolean.TRUE); } private Object resolveChatModel(Agent agent) { diff --git a/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentModerationTest.java b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentModerationTest.java index 761f083885393..2f58c6eb60c53 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentModerationTest.java +++ b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentModerationTest.java @@ -40,6 +40,7 @@ import org.apache.camel.component.mock.MockEndpoint; import org.apache.camel.spi.Registry; import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -51,6 +52,12 @@ class LangChain4jAgentModerationTest extends CamelTestSupport { private final AtomicReference moderatedExchange = new AtomicReference<>(); private final AtomicInteger chatInvocations = new AtomicInteger(); + @BeforeEach + void resetModeratedExchange() { + moderatedExchange.set(null); + chatInvocations.set(0); + } + @Override protected void bindToRegistry(Registry registry) { registry.bind("moderatedAgentConfig", moderatedAgentConfiguration(chatInvocations)); @@ -106,7 +113,6 @@ void shouldExposeModerationFlaggedHeaderWhenInputIsFlagged() throws Exception { @Test void shouldNotInvokeChatModelWhenInputIsFlagged() throws Exception { - chatInvocations.set(0); MockEndpoint mock = getMockEndpoint("mock:result"); mock.expectedMessageCount(0); diff --git a/core/camel-core-model/src/generated/resources/META-INF/services/org/apache/camel/model.properties b/core/camel-core-model/src/generated/resources/META-INF/services/org/apache/camel/model.properties index e56e3291c84dc..40778d6b77e06 100644 --- a/core/camel-core-model/src/generated/resources/META-INF/services/org/apache/camel/model.properties +++ b/core/camel-core-model/src/generated/resources/META-INF/services/org/apache/camel/model.properties @@ -24,6 +24,7 @@ convertBodyTo convertHeaderTo convertVariableTo crypto +csimple csv custom customLoadBalancer From 490b10d3a1848170f9602b7282f70ab701e64f2c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 17:49:21 +0000 Subject: [PATCH 4/5] CAMEL-23954: Address @davsclaus review on moderation PR - Remove stale csimple re-addition from model.properties - Restore AbstractAgent.configureBuilder(AiServices) signature - Rethrow moderation provider failures instead of wrapping as flagged - Fail closed on missing verdict without marking input as flagged - Fix FQCN usage in moderation tests and add provider failure tests - Document moderation ordering vs guardrails and fix component doc wording Co-authored-by: Cursor --- .../docs/langchain4j-agent-component.adoc | 2 +- .../docs/langchain4j-agent-guardrails.adoc | 8 +++- .../langchain4j/agent/api/AbstractAgent.java | 3 +- .../agent/api/AgentWithMemory.java | 15 ++++-- .../agent/api/AgentWithoutMemory.java | 15 ++++-- .../agent/api/ModerationSupport.java | 29 +++++------ .../agent/api/AgentModerationTest.java | 48 ++++++++++++++++--- .../docs/langchain4j-agent-component.adoc | 2 +- .../docs/langchain4j-agent-guardrails.adoc | 8 +++- .../agent/LangChain4jAgentModerationTest.java | 3 +- .../org/apache/camel/model.properties | 1 - 11 files changed, 91 insertions(+), 43 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc index 858d1efea1ca1..71e28b536f502 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc @@ -181,7 +181,7 @@ Agents are configured using the `AgentConfiguration` class which provides a flue ==== Content moderation -When `AgentConfiguration.withModerationModel(ModerationModel)` is set, Camel pre-moderates the user message before invoking the chat model. Flagged input raises `ModerationException` before tools run or memory is updated. The producer sets the `CamelLangChain4jAgentModerationFlagged` header to `true` on failure so routes can return a controlled refusal. See xref:others:langchain4j-agent-guardrails.adoc#_content_moderation[Content Moderation] for route examples. Category details are available on `${exception.moderation}` when handling `ModerationException`. +When `AgentConfiguration.withModerationModel(ModerationModel)` is set, Camel pre-moderates the user message before invoking the chat model. Flagged input raises `ModerationException` before tools run or memory is updated. The producer sets the `CamelLangChain4jAgentModerationFlagged` header to `true` on failure so routes can return a controlled refusal. See xref:others:langchain4j-agent-guardrails.adoc#_content_moderation[Content Moderation] for route examples. The flagged text is available via `${exception.moderation}` when handling `ModerationException`. ._Java-only: public support chatbot with moderation_ [source,java] diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-guardrails.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-guardrails.adoc index f88798a195e35..41244944ffb10 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-guardrails.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-guardrails.adoc @@ -612,9 +612,9 @@ public class AgentConfig { === Content Moderation -Configure a LangChain4j `ModerationModel` on `AgentConfiguration.withModerationModel(...)`. Camel pre-moderates the user message before the chat model is invoked, so flagged input raises `ModerationException` before tools run or conversation memory is updated. The producer sets the `CamelLangChain4jAgentModerationFlagged` header to `true` when moderation fails so routes can return a controlled refusal. +Configure a LangChain4j `ModerationModel` on `AgentConfiguration.withModerationModel(...)`. Camel pre-moderates the user message before the chat model is invoked, so flagged input raises `ModerationException` before tools run or conversation memory is updated. The producer sets the `CamelLangChain4jAgentModerationFlagged` header to `true` when moderation flags the input so routes can return a controlled refusal. Provider infrastructure failures are rethrown without setting the flagged header. -Moderation complements guardrails: guardrails are deterministic Java rules you implement; moderation delegates policy checks to a provider API (OpenAI Moderation, Mistral moderation, etc.). Only the user message text is moderated; system prompts configured by the route author are not sent to the moderation model. +Moderation runs before LangChain4j input guardrails, which execute inside the AiServices call. Moderation complements guardrails: guardrails are deterministic Java rules you implement; moderation delegates policy checks to a provider API (OpenAI Moderation, Mistral moderation, etc.). Only the user message text is moderated; system prompts configured by the route author are not sent to the moderation model. Custom `Agent` implementations should call `ModerationSupport.moderateUserMessage(...)` when they bypass `AgentWithMemory` / `AgentWithoutMemory`. Moderation failures are policy rejections (`GenAiErrorCategory.VALIDATION`), not malformed requests to retry blindly. @@ -644,6 +644,10 @@ In Spring Boot, inject an auto-configured `ModerationModel` bean from the LangCh |=== |Guardrail |When Triggered |Result +|`Content moderation` +|User message flagged by moderation model +|Blocks the request before input guardrails and chat model + |`InputLengthGuardrail` |Input too short/long |Blocks the request diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java index dc52e042ea334..e000b533d4b14 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java @@ -125,8 +125,7 @@ public void setResponseFormat(ResponseFormat responseFormat) { * @param builder the AI services builder to configure * @param toolProvider the Apache Camel tool provider (may be null) */ - @SuppressWarnings("unchecked") - protected void configureBuilder(AiServices builder, ToolProvider toolProvider) { + protected void configureBuilder(AiServices builder, ToolProvider toolProvider) { // Collect all tool providers to compose them into a single provider List toolProviders = new ArrayList<>(); diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithMemory.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithMemory.java index 7e9e068ff7680..a14a13d0a98e2 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithMemory.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithMemory.java @@ -38,22 +38,29 @@ public AgentWithMemory(AgentConfiguration configuration) { public Result chat(AiAgentBody aiAgentBody, ToolProvider toolProvider) { ModerationSupport.moderateUserMessage(configuration.getModerationModel(), aiAgentBody.getUserMessage()); + AiAgentWithMemoryService agentService = createAiAgentService(toolProvider); + String userMessage = aiAgentBody.getUserMessage(); Object memoryId = aiAgentBody.getMemoryId(); String systemMessage = aiAgentBody.getSystemMessage(); Content content = aiAgentBody.getContent(); - AiAgentWithMemoryService agentService = createAiAgentService(toolProvider); if (content != null) { + // Multi-modal message with content return systemMessage != null ? agentService.chat(memoryId, userMessage, content, systemMessage) : agentService.chat(memoryId, userMessage, content); + } else { + // Text-only message + return systemMessage != null + ? agentService.chat(memoryId, userMessage, systemMessage) + : agentService.chat(memoryId, userMessage); } - return systemMessage != null - ? agentService.chat(memoryId, userMessage, systemMessage) - : agentService.chat(memoryId, userMessage); } + /** + * Create AI service with memory provider and common configurations. + */ private AiAgentWithMemoryService createAiAgentService(ToolProvider toolProvider) { var builder = AiServices.builder(AiAgentWithMemoryService.class) .chatModel(configuration.getChatModel()) diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithoutMemory.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithoutMemory.java index 027786d9455e0..7970237d3276c 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithoutMemory.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithoutMemory.java @@ -38,21 +38,28 @@ public AgentWithoutMemory(AgentConfiguration configuration) { public Result chat(AiAgentBody aiAgentBody, ToolProvider toolProvider) { ModerationSupport.moderateUserMessage(configuration.getModerationModel(), aiAgentBody.getUserMessage()); + AiAgentWithoutMemoryService agentService = createAiAgentService(toolProvider); + String userMessage = aiAgentBody.getUserMessage(); String systemMessage = aiAgentBody.getSystemMessage(); Content content = aiAgentBody.getContent(); - AiAgentWithoutMemoryService agentService = createAiAgentService(toolProvider); if (content != null) { + // Multi-modal message with content return systemMessage != null ? agentService.chat(userMessage, content, systemMessage) : agentService.chat(userMessage, content); + } else { + // Text-only message + return systemMessage != null + ? agentService.chat(userMessage, systemMessage) + : agentService.chat(userMessage); } - return systemMessage != null - ? agentService.chat(userMessage, systemMessage) - : agentService.chat(userMessage); } + /** + * Create AI service with common configurations (no memory provider). + */ private AiAgentWithoutMemoryService createAiAgentService(ToolProvider toolProvider) { var builder = AiServices.builder(AiAgentWithoutMemoryService.class) .chatModel(configuration.getChatModel()); diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/ModerationSupport.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/ModerationSupport.java index 81cf3c7226026..1999f64de2100 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/ModerationSupport.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/ModerationSupport.java @@ -42,7 +42,8 @@ private ModerationSupport() { *

    * Only non-empty user message text is moderated. Multimodal {@code Content} without a user message is not sent to * the moderation model. When a moderation model is configured, a missing verdict fails closed and raises - * {@link ModerationException}. + * {@link ModerationException} without marking the input as flagged. Provider infrastructure failures (timeouts, + * rate limits, auth errors) are rethrown so callers can classify and retry them appropriately. * * @param moderationModel the moderation model, may be {@code null} * @param userMessage the user message to check @@ -53,24 +54,16 @@ public static void moderateUserMessage(ModerationModel moderationModel, String u return; } - try { - Response response = moderationModel.moderate(userMessage); - if (response == null || response.content() == null) { - throw new ModerationException( - "Moderation model returned no verdict for user message", - Moderation.flagged(userMessage)); - } - - Moderation moderation = response.content(); - if (moderation.flagged()) { - throw new ModerationException("User message flagged by moderation model", moderation); - } - } catch (ModerationException e) { - throw e; - } catch (RuntimeException e) { + Response response = moderationModel.moderate(userMessage); + if (response == null || response.content() == null) { throw new ModerationException( - "Moderation model failed to evaluate user message: " + e.getMessage(), - Moderation.flagged(userMessage)); + "Moderation model returned no verdict for user message", + Moderation.notFlagged()); + } + + Moderation moderation = response.content(); + if (moderation.flagged()) { + throw new ModerationException("User message flagged by moderation model", moderation); } } } diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentModerationTest.java b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentModerationTest.java index 76d1a67ec39db..1277520cf806f 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentModerationTest.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentModerationTest.java @@ -20,11 +20,15 @@ import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.memory.chat.ChatMemoryProvider; +import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.response.ChatResponse; import dev.langchain4j.model.moderation.Moderation; import dev.langchain4j.model.moderation.ModerationModel; +import dev.langchain4j.model.moderation.ModerationRequest; +import dev.langchain4j.model.moderation.ModerationResponse; +import dev.langchain4j.model.output.Response; import dev.langchain4j.service.ModerationException; import dev.langchain4j.service.Result; import org.junit.jupiter.api.Test; @@ -81,7 +85,7 @@ void agentWithoutMemorySkipsModerationWhenModelNotConfigured() { @Test void agentWithMemoryRejectsFlaggedInputWhenModerationConfigured() { - ChatMemoryProvider memoryProvider = memoryId -> dev.langchain4j.memory.chat.MessageWindowChatMemory.builder() + ChatMemoryProvider memoryProvider = memoryId -> MessageWindowChatMemory.builder() .id(memoryId) .maxMessages(10) .build(); @@ -97,7 +101,7 @@ void agentWithMemoryRejectsFlaggedInputWhenModerationConfigured() { @Test void agentWithMemoryDoesNotInvokeChatModelWhenInputIsFlagged() { AtomicInteger chatInvocations = new AtomicInteger(); - ChatMemoryProvider memoryProvider = memoryId -> dev.langchain4j.memory.chat.MessageWindowChatMemory.builder() + ChatMemoryProvider memoryProvider = memoryId -> MessageWindowChatMemory.builder() .id(memoryId) .maxMessages(10) .build(); @@ -114,7 +118,7 @@ void agentWithMemoryDoesNotInvokeChatModelWhenInputIsFlagged() { @Test void agentWithMemoryAllowsCleanInputWhenModerationConfigured() { - ChatMemoryProvider memoryProvider = memoryId -> dev.langchain4j.memory.chat.MessageWindowChatMemory.builder() + ChatMemoryProvider memoryProvider = memoryId -> MessageWindowChatMemory.builder() .id(memoryId) .maxMessages(10) .build(); @@ -132,8 +136,7 @@ void agentWithMemoryAllowsCleanInputWhenModerationConfigured() { void moderationSupportFailsClosedWhenModelReturnsNoVerdict() { ModerationModel moderationModel = new ModerationModel() { @Override - public dev.langchain4j.model.moderation.ModerationResponse doModerate( - dev.langchain4j.model.moderation.ModerationRequest request) { + public Response moderate(String text) { return null; } }; @@ -143,8 +146,39 @@ public dev.langchain4j.model.moderation.ModerationResponse doModerate( .satisfies(error -> { ModerationException moderationException = (ModerationException) error; assertThat(moderationException.moderation()).isNotNull(); - assertThat(moderationException.moderation().flagged()).isTrue(); + assertThat(moderationException.moderation().flagged()).isFalse(); + }); + } + + @Test + void moderationSupportRethrowsProviderFailures() { + RuntimeException providerFailure = new RuntimeException("moderation API unavailable"); + ModerationModel moderationModel = new ModerationModel() { + @Override + public ModerationResponse doModerate(ModerationRequest request) { + throw providerFailure; + } + }; + + assertThatThrownBy(() -> ModerationSupport.moderateUserMessage(moderationModel, "hello")) + .isSameAs(providerFailure); + } + + @Test + void agentWithoutMemoryRethrowsModerationProviderFailures() { + RuntimeException providerFailure = new RuntimeException("moderation API unavailable"); + AgentConfiguration configuration = new AgentConfiguration() + .withChatModel(countingChatModel(new AtomicInteger())) + .withModerationModel(new ModerationModel() { + @Override + public ModerationResponse doModerate(ModerationRequest request) { + throw providerFailure; + } }); + Agent agent = new AgentWithoutMemory(configuration); + + assertThatThrownBy(() -> agent.chat(new AiAgentBody<>("hello"), null)) + .isSameAs(providerFailure); } @Test @@ -152,7 +186,7 @@ void agentWithMemoryDoesNotAccessMemoryProviderWhenInputIsFlagged() { AtomicInteger memoryProviderInvocations = new AtomicInteger(); ChatMemoryProvider memoryProvider = memoryId -> { memoryProviderInvocations.incrementAndGet(); - return dev.langchain4j.memory.chat.MessageWindowChatMemory.builder() + return MessageWindowChatMemory.builder() .id(memoryId) .maxMessages(10) .build(); diff --git a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc index 858d1efea1ca1..71e28b536f502 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc +++ b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc @@ -181,7 +181,7 @@ Agents are configured using the `AgentConfiguration` class which provides a flue ==== Content moderation -When `AgentConfiguration.withModerationModel(ModerationModel)` is set, Camel pre-moderates the user message before invoking the chat model. Flagged input raises `ModerationException` before tools run or memory is updated. The producer sets the `CamelLangChain4jAgentModerationFlagged` header to `true` on failure so routes can return a controlled refusal. See xref:others:langchain4j-agent-guardrails.adoc#_content_moderation[Content Moderation] for route examples. Category details are available on `${exception.moderation}` when handling `ModerationException`. +When `AgentConfiguration.withModerationModel(ModerationModel)` is set, Camel pre-moderates the user message before invoking the chat model. Flagged input raises `ModerationException` before tools run or memory is updated. The producer sets the `CamelLangChain4jAgentModerationFlagged` header to `true` on failure so routes can return a controlled refusal. See xref:others:langchain4j-agent-guardrails.adoc#_content_moderation[Content Moderation] for route examples. The flagged text is available via `${exception.moderation}` when handling `ModerationException`. ._Java-only: public support chatbot with moderation_ [source,java] diff --git a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-guardrails.adoc b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-guardrails.adoc index f88798a195e35..41244944ffb10 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-guardrails.adoc +++ b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-guardrails.adoc @@ -612,9 +612,9 @@ public class AgentConfig { === Content Moderation -Configure a LangChain4j `ModerationModel` on `AgentConfiguration.withModerationModel(...)`. Camel pre-moderates the user message before the chat model is invoked, so flagged input raises `ModerationException` before tools run or conversation memory is updated. The producer sets the `CamelLangChain4jAgentModerationFlagged` header to `true` when moderation fails so routes can return a controlled refusal. +Configure a LangChain4j `ModerationModel` on `AgentConfiguration.withModerationModel(...)`. Camel pre-moderates the user message before the chat model is invoked, so flagged input raises `ModerationException` before tools run or conversation memory is updated. The producer sets the `CamelLangChain4jAgentModerationFlagged` header to `true` when moderation flags the input so routes can return a controlled refusal. Provider infrastructure failures are rethrown without setting the flagged header. -Moderation complements guardrails: guardrails are deterministic Java rules you implement; moderation delegates policy checks to a provider API (OpenAI Moderation, Mistral moderation, etc.). Only the user message text is moderated; system prompts configured by the route author are not sent to the moderation model. +Moderation runs before LangChain4j input guardrails, which execute inside the AiServices call. Moderation complements guardrails: guardrails are deterministic Java rules you implement; moderation delegates policy checks to a provider API (OpenAI Moderation, Mistral moderation, etc.). Only the user message text is moderated; system prompts configured by the route author are not sent to the moderation model. Custom `Agent` implementations should call `ModerationSupport.moderateUserMessage(...)` when they bypass `AgentWithMemory` / `AgentWithoutMemory`. Moderation failures are policy rejections (`GenAiErrorCategory.VALIDATION`), not malformed requests to retry blindly. @@ -644,6 +644,10 @@ In Spring Boot, inject an auto-configured `ModerationModel` bean from the LangCh |=== |Guardrail |When Triggered |Result +|`Content moderation` +|User message flagged by moderation model +|Blocks the request before input guardrails and chat model + |`InputLengthGuardrail` |Input too short/long |Blocks the request diff --git a/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentModerationTest.java b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentModerationTest.java index 2f58c6eb60c53..6943d288cfa0b 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentModerationTest.java +++ b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentModerationTest.java @@ -23,6 +23,7 @@ import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.memory.chat.ChatMemoryProvider; +import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.response.ChatResponse; @@ -166,7 +167,7 @@ public ChatResponse doChat(ChatRequest request) { } private static AgentConfiguration memoryModeratedAgentConfiguration(AtomicInteger chatInvocations) { - ChatMemoryProvider memoryProvider = memoryId -> dev.langchain4j.memory.chat.MessageWindowChatMemory.builder() + ChatMemoryProvider memoryProvider = memoryId -> MessageWindowChatMemory.builder() .id(memoryId) .maxMessages(10) .build(); diff --git a/core/camel-core-model/src/generated/resources/META-INF/services/org/apache/camel/model.properties b/core/camel-core-model/src/generated/resources/META-INF/services/org/apache/camel/model.properties index 40778d6b77e06..e56e3291c84dc 100644 --- a/core/camel-core-model/src/generated/resources/META-INF/services/org/apache/camel/model.properties +++ b/core/camel-core-model/src/generated/resources/META-INF/services/org/apache/camel/model.properties @@ -24,7 +24,6 @@ convertBodyTo convertHeaderTo convertVariableTo crypto -csimple csv custom customLoadBalancer From f4f78beb25d9b4610e0936943fa6edc926d8326d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 9 Sep 2026 06:30:26 +0000 Subject: [PATCH 5/5] Regen --- .../camel/catalog/dev-consoles-openapi.json | 10 ++++++++++ .../catalog/dev-consoles/route-topology.json | 19 +++++++++++++++++-- ...angChain4jAgentEndpointBuilderFactory.java | 14 ++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json index e3d001e3183e4..b980d31ba932a 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json @@ -6842,6 +6842,16 @@ "default": false } }, + { + "name": "kamelets", + "in": "query", + "description": "Whether to include routes created by Kamelets. These are hidden by default, as they are an implementation detail of the Kamelet", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + }, { "name": "metric", "in": "query", diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/route-topology.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/route-topology.json index 7c4fe2e36fb6f..26284535e9337 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/route-topology.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/route-topology.json @@ -28,9 +28,24 @@ "defaultValue": false, "description": "Whether to include external systems (databases, messaging brokers, etc.) as nodes" }, - "metric": { + "kamelets": { "index": 1, "kind": "option", + "displayName": "Kamelets", + "group": "query", + "label": "query", + "required": false, + "type": "boolean", + "javaType": "java.lang.Boolean", + "deprecated": false, + "autowired": false, + "secret": false, + "defaultValue": false, + "description": "Whether to include routes created by Kamelets. These are hidden by default, as they are an implementation detail of the Kamelet" + }, + "metric": { + "index": 2, + "kind": "option", "displayName": "Metric", "group": "query", "label": "query", @@ -44,7 +59,7 @@ "description": "Whether to include live metrics (message counts) on nodes and edges" }, "routes": { - "index": 2, + "index": 3, "kind": "option", "displayName": "Routes", "group": "query", diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jAgentEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jAgentEndpointBuilderFactory.java index 9acf041cfb2f7..64d267adea907 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jAgentEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jAgentEndpointBuilderFactory.java @@ -724,6 +724,20 @@ public String langChain4jAgentSources() { public String langChain4jAgentToolExecutions() { return "CamelLangChain4jAgentToolExecutions"; } + /** + * Set to true when user input is rejected by the configured moderation + * model; not set on success. + * + * The option is a: {@code Boolean} type. + * + * Group: producer + * + * @return the name of the header {@code + * LangChain4jAgentModerationFlagged}. + */ + public String langChain4jAgentModerationFlagged() { + return "CamelLangChain4jAgentModerationFlagged"; + } } static LangChain4jAgentEndpointBuilder endpointBuilder(String componentName, String path) { class LangChain4jAgentEndpointBuilderImpl extends AbstractEndpointBuilder implements LangChain4jAgentEndpointBuilder, AdvancedLangChain4jAgentEndpointBuilder {