From 0db6af9334ea3b9c87ad32bcf5b83dabea901bc4 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Fri, 8 May 2026 20:33:38 +0530 Subject: [PATCH 01/70] Minor changes pushed in the example component --- example/servicedef/services.xml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/example/servicedef/services.xml b/example/servicedef/services.xml index fcd99a1ff..66227d0c8 100644 --- a/example/servicedef/services.xml +++ b/example/servicedef/services.xml @@ -65,7 +65,7 @@ under the License. - Create a ExampleItem + Create an ExampleItem @@ -73,52 +73,52 @@ under the License. - Update a ExampleItem + Update an ExampleItem - Delete a ExampleItem + Delete an ExampleItem - Create a ExampleFeature + Create an ExampleFeature - Update a ExampleFeature + Update an ExampleFeature - Delete a ExampleFeature + Delete an ExampleFeature - Create a ExampleFeatureAppl + Create an ExampleFeatureAppl - Update a ExampleFeatureAppl + Update an ExampleFeatureAppl - Delete a ExampleFeatureAppl + Delete an ExampleFeatureAppl @@ -156,17 +156,17 @@ under the License. - Create a ExampleFeatureApplType + Create an ExampleFeatureApplType - Update a ExampleFeatureApplType + Update an ExampleFeatureApplType - Delete a ExampleFeatureApplType + Delete an ExampleFeatureApplType From 6731a9416e566b33b4e0d2a929fff72374469e76 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Thu, 14 May 2026 19:08:15 +0530 Subject: [PATCH 02/70] =?UTF-8?q?Add=20ai=20plugin=20gitignore=20entries?= =?UTF-8?q?=20=E2=80=94=20CLAUDE.md=20and=20ai.properties?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 19aa9488a..5d8480b91 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .DS_Store bin/ node_modules/ +ai/config/ai.properties +ai/CLAUDE.md From 54abc1f548c9066010bead7e2fabbc154ec30dbd Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Thu, 14 May 2026 19:30:11 +0530 Subject: [PATCH 03/70] Step 1: Add ai plugin skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ofbiz-component.xml: registers plugin, container, service resource - build.gradle: LangChain4j 1.8.0 dependencies (Apache 2.0) - servicedef/services.xml: empty stub (populated in Step 6) - config/ai.properties: gitignored, template only Plugin compiles cleanly into root OFBiz jar. Note: ai.properties is gitignored — not committed. Ref: https://github.com/patelanil/ofbiz-dev/issues/1 OFBIZ-13408 --- ai/build.gradle | 23 +++++++++++++++++++++++ ai/ofbiz-component.xml | 32 ++++++++++++++++++++++++++++++++ ai/servicedef/services.xml | 21 +++++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 ai/build.gradle create mode 100644 ai/ofbiz-component.xml create mode 100644 ai/servicedef/services.xml diff --git a/ai/build.gradle b/ai/build.gradle new file mode 100644 index 000000000..684f7763b --- /dev/null +++ b/ai/build.gradle @@ -0,0 +1,23 @@ +/* + * 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. + */ + +dependencies { + pluginLibsCompile 'dev.langchain4j:langchain4j:1.8.0' + pluginLibsCompile 'dev.langchain4j:langchain4j-open-ai:1.8.0' +} diff --git a/ai/ofbiz-component.xml b/ai/ofbiz-component.xml new file mode 100644 index 000000000..6f4a3539d --- /dev/null +++ b/ai/ofbiz-component.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + diff --git a/ai/servicedef/services.xml b/ai/servicedef/services.xml new file mode 100644 index 000000000..0a183c936 --- /dev/null +++ b/ai/servicedef/services.xml @@ -0,0 +1,21 @@ + + + + From 7ac0301b3e622311ad7aa6696d92a139651a9f5d Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Thu, 14 May 2026 19:50:08 +0530 Subject: [PATCH 04/70] =?UTF-8?q?Step=202:=20Add=20AiContainer.java=20?= =?UTF-8?q?=E2=80=94=20provider-agnostic=20Container=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implements Container interface following BirtContainer pattern - init() stores name and configFile only - start() reads provider-agnostic ai.properties: ai.provider, ai.model, ai.apiKey, ai.baseUrl, ai.timeout - Validates apiKey — fails fast with clear error if not configured - Provider switch builds ChatModel interface (not OpenAiChatModel) - openai case covers OpenAI, Groq, Ollama, Azure via baseUrl - Additional providers (anthropic, bedrock) can be added in switch - Stores singleton via AiFactory.setChatModel() (Step 3) - stop() calls AiFactory.destroy() - Note: ai.properties is gitignored — not committed Ref: https://github.com/patelanil/ofbiz-dev/issues/1 OFBIZ-13408 --- .../ofbiz/ai/container/AiContainer.java | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 ai/src/main/java/org/apache/ofbiz/ai/container/AiContainer.java diff --git a/ai/src/main/java/org/apache/ofbiz/ai/container/AiContainer.java b/ai/src/main/java/org/apache/ofbiz/ai/container/AiContainer.java new file mode 100644 index 000000000..b2a63b500 --- /dev/null +++ b/ai/src/main/java/org/apache/ofbiz/ai/container/AiContainer.java @@ -0,0 +1,97 @@ +/******************************************************************************* + * 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.ofbiz.ai.container; + +import java.time.Duration; +import java.util.List; + +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.openai.OpenAiChatModel; + +import org.apache.ofbiz.ai.AiFactory; +import org.apache.ofbiz.base.container.Container; +import org.apache.ofbiz.base.container.ContainerException; +import org.apache.ofbiz.base.start.StartupCommand; +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.UtilProperties; +import org.apache.ofbiz.base.util.UtilValidate; + +public class AiContainer implements Container { + + private static final String MODULE = AiContainer.class.getName(); + + private String name; + private String configFile; + + @Override + public void init(List ofbizCommands, String name, String configFile) throws ContainerException { + this.name = name; + this.configFile = configFile; + } + + @Override + public boolean start() throws ContainerException { + String provider = UtilProperties.getPropertyValue("ai", "ai.provider", "openai"); + String model = UtilProperties.getPropertyValue("ai", "ai.model", "gpt-4o-mini"); + String apiKey = UtilProperties.getPropertyValue("ai", "ai.apiKey"); + String baseUrl = UtilProperties.getPropertyValue("ai", "ai.baseUrl", ""); + int timeoutSecs; + try { + timeoutSecs = Integer.parseInt( + UtilProperties.getPropertyValue("ai", "ai.timeout", "60")); + } catch (NumberFormatException e) { + timeoutSecs = 60; + } + + if (UtilValidate.isEmpty(apiKey) || "REPLACE_WITH_YOUR_API_KEY".equals(apiKey)) { + Debug.logError("AI plugin: ai.apiKey is not configured in ai.properties", MODULE); + return false; + } + + ChatModel chatModel; + // Additional providers (anthropic, ollama native, bedrock) + // can be added here with their respective LangChain4j builders + switch (provider) { + case "openai": + default: + var builder = OpenAiChatModel.builder() + .apiKey(apiKey) + .modelName(model) + .timeout(Duration.ofSeconds(timeoutSecs)); + if (UtilValidate.isNotEmpty(baseUrl)) { + builder.baseUrl(baseUrl); + } + chatModel = builder.build(); + } + + AiFactory.setChatModel(chatModel); + Debug.logInfo("AI plugin initialized: provider=" + provider + " model=" + model, MODULE); + return true; + } + + @Override + public void stop() throws ContainerException { + AiFactory.destroy(); + } + + @Override + public String getName() { + return name; + } +} From b46c1c557a1e25e8c7e51625db0f4cd928024bd1 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Thu, 14 May 2026 20:06:15 +0530 Subject: [PATCH 05/70] =?UTF-8?q?Step=203:=20Add=20AiFactory.java=20?= =?UTF-8?q?=E2=80=94=20singleton=20ChatModel=20holder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Static factory parallel to BirtFactory pattern - setChatModel(ChatModel) — called by AiContainer.start() - getChatModel() — throws IllegalStateException if not initialized - destroy() — called by AiContainer.stop() - Compiles cleanly with AiContainer Ref: https://github.com/patelanil/ofbiz-dev/issues/1 OFBIZ-13408 --- .../java/org/apache/ofbiz/ai/AiFactory.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 ai/src/main/java/org/apache/ofbiz/ai/AiFactory.java diff --git a/ai/src/main/java/org/apache/ofbiz/ai/AiFactory.java b/ai/src/main/java/org/apache/ofbiz/ai/AiFactory.java new file mode 100644 index 000000000..6f3493299 --- /dev/null +++ b/ai/src/main/java/org/apache/ofbiz/ai/AiFactory.java @@ -0,0 +1,43 @@ +/******************************************************************************* + * 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.ofbiz.ai; + +import dev.langchain4j.model.chat.ChatModel; + +public class AiFactory { + + private static final String MODULE = AiFactory.class.getName(); + + private static ChatModel chatModel; + + public static void setChatModel(ChatModel model) { + AiFactory.chatModel = model; + } + + public static ChatModel getChatModel() { + if (chatModel == null) { + throw new IllegalStateException("AI plugin is not initialized. Check ai.properties configuration."); + } + return chatModel; + } + + public static void destroy() { + chatModel = null; + } +} From e64106337810bdb45f35364f82d266605f12713c Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Thu, 14 May 2026 20:16:47 +0530 Subject: [PATCH 06/70] =?UTF-8?q?Step=204:=20Add=20AiWorker.java=20?= =?UTF-8?q?=E2=80=94=20static=20utility=20for=20AI=20calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - generate(dctx, messages) → String - generateStructured(dctx, messages, schema) → Map - TYPE_BUILDERS map pattern — no switch, extensible - JSON Schema vocabulary: string, number, integer, boolean, array, object - toChatMessages() converts List to LangChain4j ChatMessage list - buildJsonObjectSchema() + buildSchemaElement() for schema conversion - ResponseFormatType.JSON (JSON_SCHEMA does not exist in LangChain4j 1.8.0) - Jackson ObjectMapper for JSON response parsing - dctx parameter present for future use (audit logging, delegator) - GeneralException wraps all failures with clear message Ref: https://github.com/patelanil/ofbiz-dev/issues/1 OFBIZ-13408 --- .../java/org/apache/ofbiz/ai/AiWorker.java | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 ai/src/main/java/org/apache/ofbiz/ai/AiWorker.java diff --git a/ai/src/main/java/org/apache/ofbiz/ai/AiWorker.java b/ai/src/main/java/org/apache/ofbiz/ai/AiWorker.java new file mode 100644 index 000000000..7de2894ec --- /dev/null +++ b/ai/src/main/java/org/apache/ofbiz/ai/AiWorker.java @@ -0,0 +1,148 @@ +/******************************************************************************* + * 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.ofbiz.ai; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.request.ResponseFormat; +import dev.langchain4j.model.chat.request.ResponseFormatType; +import dev.langchain4j.model.chat.request.json.JsonArraySchema; +import dev.langchain4j.model.chat.request.json.JsonBooleanSchema; +import dev.langchain4j.model.chat.request.json.JsonIntegerSchema; +import dev.langchain4j.model.chat.request.json.JsonNumberSchema; +import dev.langchain4j.model.chat.request.json.JsonObjectSchema; +import dev.langchain4j.model.chat.request.json.JsonSchema; +import dev.langchain4j.model.chat.request.json.JsonSchemaElement; +import dev.langchain4j.model.chat.request.json.JsonStringSchema; + +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.GeneralException; +import org.apache.ofbiz.base.util.UtilGenerics; +import org.apache.ofbiz.service.DispatchContext; + +public final class AiWorker { + + private static final String MODULE = AiWorker.class.getName(); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final Map> TYPE_BUILDERS = new HashMap<>(); + static { + TYPE_BUILDERS.put("string", JsonStringSchema::new); + TYPE_BUILDERS.put("number", JsonNumberSchema::new); + TYPE_BUILDERS.put("integer", JsonIntegerSchema::new); + TYPE_BUILDERS.put("boolean", JsonBooleanSchema::new); + } + + private AiWorker() { } + + public static String generate(DispatchContext dctx, + List> messages) throws GeneralException { + try { + List chatMessages = toChatMessages(messages); + var chatModel = AiFactory.getChatModel(); + var request = ChatRequest.builder().messages(chatMessages).build(); + var response = chatModel.chat(request); + return response.aiMessage().text(); + } catch (Exception e) { + Debug.logError(e, "AI generate failed", MODULE); + throw new GeneralException("AI generate failed: " + e.getMessage(), e); + } + } + + public static Map generateStructured(DispatchContext dctx, + List> messages, + Map schema) throws GeneralException { + try { + List chatMessages = toChatMessages(messages); + JsonObjectSchema jsonObjectSchema = buildJsonObjectSchema(schema); + JsonSchema jsonSchema = JsonSchema.builder() + .name("response").rootElement(jsonObjectSchema).build(); + ResponseFormat responseFormat = ResponseFormat.builder() + .type(ResponseFormatType.JSON).jsonSchema(jsonSchema).build(); + var chatModel = AiFactory.getChatModel(); + var request = ChatRequest.builder() + .messages(chatMessages).responseFormat(responseFormat).build(); + var response = chatModel.chat(request); + return OBJECT_MAPPER.readValue(response.aiMessage().text(), + new TypeReference>() { }); + } catch (Exception e) { + Debug.logError(e, "AI generateStructured failed", MODULE); + throw new GeneralException("AI generateStructured failed: " + e.getMessage(), e); + } + } + + private static List toChatMessages(List> messages) { + List chatMessages = new ArrayList<>(); + for (Map msg : messages) { + String role = (String) msg.get("role"); + String content = (String) msg.get("content"); + if ("system".equals(role)) { + chatMessages.add(SystemMessage.from(content)); + } else if ("assistant".equals(role)) { + chatMessages.add(AiMessage.from(content)); + } else { + chatMessages.add(UserMessage.from(content)); + } + } + return chatMessages; + } + + private static JsonObjectSchema buildJsonObjectSchema(Map schemaMap) { + JsonObjectSchema.Builder builder = JsonObjectSchema.builder(); + for (Map.Entry entry : schemaMap.entrySet()) { + builder.addProperty(entry.getKey(), buildSchemaElement(entry.getValue())); + } + return builder.build(); + } + + private static JsonSchemaElement buildSchemaElement(Object descriptor) { + if (descriptor instanceof String type) { + if ("array".equals(type)) return JsonArraySchema.builder().build(); + if ("object".equals(type)) return JsonObjectSchema.builder().build(); + return TYPE_BUILDERS.getOrDefault(type, JsonStringSchema::new).get(); + } + if (descriptor instanceof Map) { + Map descMap = UtilGenerics.cast(descriptor); + String type = (String) descMap.get("type"); + if ("array".equals(type)) { + JsonArraySchema.Builder ab = JsonArraySchema.builder(); + if (descMap.containsKey("items")) ab.items(buildSchemaElement(descMap.get("items"))); + return ab.build(); + } + if ("object".equals(type)) { + Object props = descMap.get("properties"); + if (props instanceof Map) return buildJsonObjectSchema(UtilGenerics.cast(props)); + return JsonObjectSchema.builder().build(); + } + if (type != null) return TYPE_BUILDERS.getOrDefault(type, JsonStringSchema::new).get(); + } + return new JsonStringSchema(); + } +} From 37f0e464177769e7b30b22dac6dde7f0cffd2642 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Thu, 14 May 2026 20:23:25 +0530 Subject: [PATCH 07/70] =?UTF-8?q?Step=205:=20Add=20AiServices.java=20?= =?UTF-8?q?=E2=80=94=20OFBiz=20service=20implementations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - generate(dctx, context) → calls AiWorker.generate, returns response - generateStructured(dctx, context) → calls AiWorker.generateStructured, returns result Map - ServiceUtil.returnSuccess/returnError pattern - UtilGenerics.cast() for unchecked context parameter casts - Pure delegation — no LangChain4j imports Ref: https://github.com/patelanil/ofbiz-dev/issues/1 OFBIZ-13408 --- .../java/org/apache/ofbiz/ai/AiServices.java | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 ai/src/main/java/org/apache/ofbiz/ai/AiServices.java diff --git a/ai/src/main/java/org/apache/ofbiz/ai/AiServices.java b/ai/src/main/java/org/apache/ofbiz/ai/AiServices.java new file mode 100644 index 000000000..47fb8207d --- /dev/null +++ b/ai/src/main/java/org/apache/ofbiz/ai/AiServices.java @@ -0,0 +1,60 @@ +/******************************************************************************* + * 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.ofbiz.ai; + +import java.util.List; +import java.util.Map; + +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.GeneralException; +import org.apache.ofbiz.base.util.UtilGenerics; +import org.apache.ofbiz.service.DispatchContext; +import org.apache.ofbiz.service.ServiceUtil; + +public class AiServices { + + private static final String MODULE = AiServices.class.getName(); + + public static Map generate(DispatchContext dctx, Map context) { + List> messages = UtilGenerics.cast(context.get("messages")); + try { + String response = AiWorker.generate(dctx, messages); + Map result = ServiceUtil.returnSuccess(); + result.put("response", response); + return result; + } catch (GeneralException e) { + Debug.logError(e, e.getMessage(), MODULE); + return ServiceUtil.returnError(e.getMessage()); + } + } + + public static Map generateStructured(DispatchContext dctx, Map context) { + List> messages = UtilGenerics.cast(context.get("messages")); + Map schema = UtilGenerics.cast(context.get("schema")); + try { + Map aiResult = AiWorker.generateStructured(dctx, messages, schema); + Map result = ServiceUtil.returnSuccess(); + result.put("result", aiResult); + return result; + } catch (GeneralException e) { + Debug.logError(e, e.getMessage(), MODULE); + return ServiceUtil.returnError(e.getMessage()); + } + } +} From 15e97e95a0bb84870750c71e6cb86a4458ff2f38 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Thu, 14 May 2026 20:27:55 +0530 Subject: [PATCH 08/70] Step 6: Add service definitions to services.xml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ai.generate: messages (List IN) → response (String OUT) - ai.generateStructured: messages (List IN) + schema (Map IN) → result (Map OUT) - configName optional IN on both services (reserved for future use) - engine=java, location=org.apache.ofbiz.ai.AiServices Ref: https://github.com/patelanil/ofbiz-dev/issues/1 OFBIZ-13408 --- ai/servicedef/services.xml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/ai/servicedef/services.xml b/ai/servicedef/services.xml index 0a183c936..ceff6b39b 100644 --- a/ai/servicedef/services.xml +++ b/ai/servicedef/services.xml @@ -18,4 +18,22 @@ under the License. + + + Generate a text response from an AI model + + + + + + + Generate a structured Map response from an AI model + + + + + + From 276b5c0c4e9a497e3715ac22436d51f47a5f5b84 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Thu, 14 May 2026 21:16:28 +0530 Subject: [PATCH 09/70] Step 7: Add smoke test service ai.smokeTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AiTest.groovy: calls AiWorker.generate with test message - ai.smokeTest service registered in services.xml - Verified end to end: response 'Hello!' received from OpenAI - Full stack confirmed: AiContainer → AiFactory → AiWorker → LangChain4j → OpenAI Ref: https://github.com/patelanil/ofbiz-dev/issues/1 OFBIZ-13408 --- ai/groovyScripts/AiTest.groovy | 15 +++++++++++++++ ai/servicedef/services.xml | 6 ++++++ 2 files changed, 21 insertions(+) create mode 100644 ai/groovyScripts/AiTest.groovy diff --git a/ai/groovyScripts/AiTest.groovy b/ai/groovyScripts/AiTest.groovy new file mode 100644 index 000000000..b87933193 --- /dev/null +++ b/ai/groovyScripts/AiTest.groovy @@ -0,0 +1,15 @@ +import org.apache.ofbiz.base.util.Debug +import org.apache.ofbiz.ai.AiWorker + +def messages = [ + [role: "user", content: "Say hello in one word."] +] + +try { + String response = AiWorker.generate(dctx, messages) + Debug.logInfo("AI smoke test response: " + response, "AiTest") + return success("AI smoke test passed: " + response) +} catch (Exception e) { + Debug.logError(e, "AI smoke test failed", "AiTest") + return error("AI smoke test failed: " + e.getMessage()) +} diff --git a/ai/servicedef/services.xml b/ai/servicedef/services.xml index ceff6b39b..bfd6b8f05 100644 --- a/ai/servicedef/services.xml +++ b/ai/servicedef/services.xml @@ -36,4 +36,10 @@ under the License. + + Smoke test for the AI plugin — calls ai.generate with a test message + + From 31aecbb23d2b8143f655c3bba4d3a5e2ed8b3767 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Thu, 14 May 2026 21:26:23 +0530 Subject: [PATCH 10/70] Step 8: Add README.md - What the plugin does and JIRA reference OFBIZ-13408 - Architecture table: AiContainer, AiFactory, AiWorker, AiServices - Installation and configuration instructions - Multiple provider support via ai.baseUrl - Usage examples: generate() and generateStructured() - Available services table with IN/OUT params - Smoke test instructions - Guide for adding new providers Ref: https://github.com/patelanil/ofbiz-dev/issues/1 OFBIZ-13408 --- ai/README.md | 136 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 ai/README.md diff --git a/ai/README.md b/ai/README.md new file mode 100644 index 000000000..50c7efbfe --- /dev/null +++ b/ai/README.md @@ -0,0 +1,136 @@ +# AI Plugin for Apache OFBiz + +LangChain4j integration that exposes AI/LLM capabilities as standard OFBiz services. + +Apache JIRA: https://issues.apache.org/jira/browse/OFBIZ-13408 + +## What this plugin does + +The AI plugin connects OFBiz to any OpenAI-compatible chat model via LangChain4j 1.8.0. +It provides two callable OFBiz services — `ai.generate` for free-text responses and +`ai.generateStructured` for JSON-schema-constrained structured output — that any other +service or Groovy script can call without depending on LangChain4j directly. + +## Architecture + +| Layer | Class | Role | +|---|---|---| +| Container | `AiContainer` | Reads `ai.properties` at startup, builds the `ChatModel`, calls `AiFactory.setChatModel()` | +| Singleton | `AiFactory` | Holds the live `ChatModel` instance; throws `IllegalStateException` if not initialized | +| Utility | `AiWorker` | Static `generate()` and `generateStructured()` methods; handles message conversion and JSON schema mapping | +| Services | `AiServices` | Thin OFBiz service wrappers that delegate to `AiWorker` and return standard service result maps | + +## Prerequisites + +- Java 17 or later +- OFBiz trunk +- An API key from OpenAI or a compatible provider (Ollama, Groq, Together, Azure OpenAI) + +## Installation + +1. The plugin is already placed at `plugins/ai/` inside the OFBiz source tree. +2. Copy the properties template and fill in your values: + ``` + cp plugins/ai/config/ai.properties.template plugins/ai/config/ai.properties + ``` + If no template exists, create `plugins/ai/config/ai.properties` from the table below. +3. Start OFBiz normally. The container will log: + ``` + AI plugin initialized: provider=openai model=gpt-4o-mini + ``` + If the API key is missing or placeholder, startup continues but the plugin logs an error and skips initialization. + +## Configuration + +Edit `plugins/ai/config/ai.properties` (this file is gitignored — never commit API keys). + +| Property | Description | Default | +|---|---|---| +| `ai.provider` | Provider name. Currently used for logging; the `openai` engine handles all OpenAI-compatible endpoints. | `openai` | +| `ai.model` | Model name passed to the provider. | `gpt-4o-mini` | +| `ai.baseUrl` | Base URL override. Leave empty for the OpenAI default. Set for local or third-party endpoints. | _(empty)_ | +| `ai.apiKey` | Your API key. **Required.** | _(none)_ | +| `ai.timeout` | Request timeout in seconds. | `60` | + +## Multiple providers + +Setting `ai.baseUrl` makes the plugin work with any OpenAI-compatible endpoint: + +| Provider | `ai.baseUrl` | +|---|---| +| OpenAI (default) | _(leave empty)_ | +| Ollama | `http://localhost:11434` | +| Groq | `https://api.groq.com/openai/v1` | +| Together AI | `https://api.together.xyz/v1` | +| Azure OpenAI | your Azure endpoint URL | + +## Usage + +### Generate free-text (from a Groovy service) + +```groovy +import org.apache.ofbiz.ai.AiWorker + +def messages = [ + [role: "system", content: "You are a helpful assistant."], + [role: "user", content: "Summarize this order in one sentence."] +] + +String response = AiWorker.generate(dctx, messages) +``` + +### Generate structured output + +Schema values can be a plain type string (`"string"`, `"number"`, `"integer"`, `"boolean"`, +`"array"`, `"object"`) or a map with `type` and optional `properties`/`items` for nested shapes. + +```groovy +import org.apache.ofbiz.ai.AiWorker + +def messages = [ + [role: "user", content: "Extract the product name and price from: 'Widget Pro costs \$49.99'"] +] + +def schema = [ + productName: "string", + price: "number" +] + +Map result = AiWorker.generateStructured(dctx, messages, schema) +// result == [productName: "Widget Pro", price: 49.99] +``` + +Both methods throw `GeneralException` on failure; callers should catch it and return +`ServiceUtil.returnError()` as appropriate. + +## Available services + +| Service | IN | OUT | Description | +|---|---|---|---| +| `ai.generate` | `messages` (List, required)
`configName` (String, optional) | `response` (String) | Free-text generation | +| `ai.generateStructured` | `messages` (List, required)
`schema` (Map, required)
`configName` (String, optional) | `result` (Map) | Structured JSON output | + +Each message in the `messages` list is a `Map` with keys `role` (`system`, `user`, or `assistant`) and `content` (String). + +## Smoke test + +With OFBiz running and a valid API key configured, invoke `ai.smokeTest` from the +webtools service runner: + +``` +https://localhost:8443/webtools/control/main +→ Service Engine → Run Service → ai.smokeTest +``` + +A successful run logs: +``` +AI smoke test response: Hello +``` + +## Adding new providers + +To support a provider that is not OpenAI-compatible (e.g., Anthropic native, Amazon Bedrock), +add a new `case` to the `switch (provider)` block in `AiContainer.java`. Instantiate the +provider's LangChain4j `ChatModel` builder, call `AiFactory.setChatModel(chatModel)`, and +add the corresponding `dev.langchain4j:langchain4j-` dependency to `build.gradle`. +No changes to `AiFactory`, `AiWorker`, or `AiServices` are needed. From fca7dc7f28b7a44581382bb74e00db5f6672c3ff Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Thu, 14 May 2026 21:41:24 +0530 Subject: [PATCH 11/70] =?UTF-8?q?Fix=20service=20naming=20convention=20?= =?UTF-8?q?=E2=80=94=20use=20camelCase=20per=20OFBiz=20standard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ai.generate → aiGenerate - ai.generateStructured → aiGenerateStructured - ai.smokeTest → aiSmokeTest Dot notation is not OFBiz convention for service names. Updated services.xml and README.md. Ref: https://github.com/patelanil/ofbiz-dev/issues/1 OFBIZ-13408 --- ai/README.md | 12 ++++++------ ai/servicedef/services.xml | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ai/README.md b/ai/README.md index 50c7efbfe..2734ad945 100644 --- a/ai/README.md +++ b/ai/README.md @@ -7,8 +7,8 @@ Apache JIRA: https://issues.apache.org/jira/browse/OFBIZ-13408 ## What this plugin does The AI plugin connects OFBiz to any OpenAI-compatible chat model via LangChain4j 1.8.0. -It provides two callable OFBiz services — `ai.generate` for free-text responses and -`ai.generateStructured` for JSON-schema-constrained structured output — that any other +It provides two callable OFBiz services — `aiGenerate` for free-text responses and +`aiGenerateStructured` for JSON-schema-constrained structured output — that any other service or Groovy script can call without depending on LangChain4j directly. ## Architecture @@ -107,19 +107,19 @@ Both methods throw `GeneralException` on failure; callers should catch it and re | Service | IN | OUT | Description | |---|---|---|---| -| `ai.generate` | `messages` (List, required)
`configName` (String, optional) | `response` (String) | Free-text generation | -| `ai.generateStructured` | `messages` (List, required)
`schema` (Map, required)
`configName` (String, optional) | `result` (Map) | Structured JSON output | +| `aiGenerate` | `messages` (List, required)
`configName` (String, optional) | `response` (String) | Free-text generation | +| `aiGenerateStructured` | `messages` (List, required)
`schema` (Map, required)
`configName` (String, optional) | `result` (Map) | Structured JSON output | Each message in the `messages` list is a `Map` with keys `role` (`system`, `user`, or `assistant`) and `content` (String). ## Smoke test -With OFBiz running and a valid API key configured, invoke `ai.smokeTest` from the +With OFBiz running and a valid API key configured, invoke `aiSmokeTest` from the webtools service runner: ``` https://localhost:8443/webtools/control/main -→ Service Engine → Run Service → ai.smokeTest +→ Service Engine → Run Service → aiSmokeTest ``` A successful run logs: diff --git a/ai/servicedef/services.xml b/ai/servicedef/services.xml index bfd6b8f05..f39b134a1 100644 --- a/ai/servicedef/services.xml +++ b/ai/servicedef/services.xml @@ -19,7 +19,7 @@ under the License. xsi:noNamespaceSchemaLocation= "https://ofbiz.apache.org/dtds/services.xsd"> - Generate a text response from an AI model @@ -27,7 +27,7 @@ under the License. - Generate a structured Map response from an AI model @@ -36,10 +36,10 @@ under the License. - - Smoke test for the AI plugin — calls ai.generate with a test message + Smoke test for the AI plugin — calls aiGenerate with a test message From b59e93642cfeffbc3cfacfb84ba65c66355b2a30 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Fri, 15 May 2026 07:31:44 +0530 Subject: [PATCH 12/70] Add aiSmokeTestStructured smoke test for generateStructured - AiStructuredTest.groovy: calls AiWorker.generateStructured with simple schema [word: 'string'] - aiSmokeTestStructured service registered in services.xml - Verified end to end: response [word:Helloreceived from OpenAI - Validates key presence in response Map Ref: https://github.com/patelanil/ofbiz-dev/issues/1 OFBIZ-13408 --- ai/groovyScripts/AiStructuredTest.groovy | 23 +++++++++++++++++++++++ ai/servicedef/services.xml | 6 ++++++ 2 files changed, 29 insertions(+) create mode 100644 ai/groovyScripts/AiStructuredTest.groovy diff --git a/ai/groovyScripts/AiStructuredTest.groovy b/ai/groovyScripts/AiStructuredTest.groovy new file mode 100644 index 000000000..a3c14d2cf --- /dev/null +++ b/ai/groovyScripts/AiStructuredTest.groovy @@ -0,0 +1,23 @@ +import org.apache.ofbiz.base.util.Debug +import org.apache.ofbiz.ai.AiWorker + +def messages = [ + [role: "user", content: "Return a greeting with a single word."] +] + +def schema = [ + word: "string" +] + +try { + Map result = AiWorker.generateStructured(dctx, messages, schema) + if (!result || !result.containsKey("word")) { + Debug.logError("AI structured smoke test failed: response missing 'word' key. Got: " + result, "AiStructuredTest") + return error("AI structured smoke test failed: missing 'word' key in response") + } + Debug.logInfo("AI structured smoke test response: " + result, "AiStructuredTest") + return success("AI structured smoke test passed: " + result) +} catch (Exception e) { + Debug.logError(e, "AI structured smoke test failed", "AiStructuredTest") + return error("AI structured smoke test failed: " + e.getMessage()) +} diff --git a/ai/servicedef/services.xml b/ai/servicedef/services.xml index f39b134a1..f98c7e374 100644 --- a/ai/servicedef/services.xml +++ b/ai/servicedef/services.xml @@ -42,4 +42,10 @@ under the License. Smoke test for the AI plugin — calls aiGenerate with a test message + + Smoke test for generateStructured — expects Map with word key + + From 4f5debc4211378edd426f5497e64b32d696b7d04 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Fri, 15 May 2026 07:43:32 +0530 Subject: [PATCH 13/70] Fix SonarCloud: replace string literals with MODULE constant - AiTest.groovy: add final String MODULE = 'AiTest.groovy' - AiStructuredTest.groovy: add final String MODULE = 'AiStructuredTest.groovy' - Replace all Debug.log string literal module arguments with MODULE - Follows OFBiz Groovy script convention (ArtifactInfo.groovy pattern) Ref: https://github.com/patelanil/ofbiz-dev/issues/1 OFBIZ-13408 --- ai/groovyScripts/AiStructuredTest.groovy | 8 +++++--- ai/groovyScripts/AiTest.groovy | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/ai/groovyScripts/AiStructuredTest.groovy b/ai/groovyScripts/AiStructuredTest.groovy index a3c14d2cf..2c4cc6d53 100644 --- a/ai/groovyScripts/AiStructuredTest.groovy +++ b/ai/groovyScripts/AiStructuredTest.groovy @@ -1,6 +1,8 @@ import org.apache.ofbiz.base.util.Debug import org.apache.ofbiz.ai.AiWorker +final String MODULE = 'AiStructuredTest.groovy' + def messages = [ [role: "user", content: "Return a greeting with a single word."] ] @@ -12,12 +14,12 @@ def schema = [ try { Map result = AiWorker.generateStructured(dctx, messages, schema) if (!result || !result.containsKey("word")) { - Debug.logError("AI structured smoke test failed: response missing 'word' key. Got: " + result, "AiStructuredTest") + Debug.logError("AI structured smoke test failed: response missing 'word' key. Got: " + result, MODULE) return error("AI structured smoke test failed: missing 'word' key in response") } - Debug.logInfo("AI structured smoke test response: " + result, "AiStructuredTest") + Debug.logInfo("AI structured smoke test response: " + result, MODULE) return success("AI structured smoke test passed: " + result) } catch (Exception e) { - Debug.logError(e, "AI structured smoke test failed", "AiStructuredTest") + Debug.logError(e, "AI structured smoke test failed", MODULE) return error("AI structured smoke test failed: " + e.getMessage()) } diff --git a/ai/groovyScripts/AiTest.groovy b/ai/groovyScripts/AiTest.groovy index b87933193..9a8597b40 100644 --- a/ai/groovyScripts/AiTest.groovy +++ b/ai/groovyScripts/AiTest.groovy @@ -1,15 +1,17 @@ import org.apache.ofbiz.base.util.Debug import org.apache.ofbiz.ai.AiWorker +final String MODULE = 'AiTest.groovy' + def messages = [ [role: "user", content: "Say hello in one word."] ] try { String response = AiWorker.generate(dctx, messages) - Debug.logInfo("AI smoke test response: " + response, "AiTest") + Debug.logInfo("AI smoke test response: " + response, MODULE) return success("AI smoke test passed: " + response) } catch (Exception e) { - Debug.logError(e, "AI smoke test failed", "AiTest") + Debug.logError(e, "AI smoke test failed", MODULE) return error("AI smoke test failed: " + e.getMessage()) } From 075787dda640e1248d8edbc652c80a53ce062675 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Sat, 16 May 2026 14:24:19 +0530 Subject: [PATCH 14/70] [AI] Step 2: Add multi-provider support and remove AiFactory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add langchain4j-anthropic:1.8.0 and langchain4j-ollama:1.8.0 to build.gradle. Refactor AiContainer to support openai, anthropic, and ollama via ai.properties config — replaces single-provider switch block with buildChatModel() if/else-if. AiContainer now holds the static ChatModel field and exposes getChatModel(), following the ServiceContainer pattern. AiFactory is deleted. Refactor AiWorker to reference AiContainer directly. Align both generate() and generateStructured() to fetch chatModel before the try block with consistent null guards — fixes a pre-existing NPE risk in generateStructured(). --- .gitignore | 5 +- ai/build.gradle | 2 + .../java/org/apache/ofbiz/ai/AiFactory.java | 43 ---------- .../java/org/apache/ofbiz/ai/AiWorker.java | 12 ++- .../ofbiz/ai/container/AiContainer.java | 78 +++++++++++++------ 5 files changed, 70 insertions(+), 70 deletions(-) delete mode 100644 ai/src/main/java/org/apache/ofbiz/ai/AiFactory.java diff --git a/.gitignore b/.gitignore index 5d8480b91..1144db3bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store bin/ node_modules/ -ai/config/ai.properties -ai/CLAUDE.md +ai/config/ai.properties +ai/CLAUDE.md +ai/docs/ diff --git a/ai/build.gradle b/ai/build.gradle index 684f7763b..91ae11591 100644 --- a/ai/build.gradle +++ b/ai/build.gradle @@ -19,5 +19,7 @@ dependencies { pluginLibsCompile 'dev.langchain4j:langchain4j:1.8.0' + pluginLibsCompile 'dev.langchain4j:langchain4j-anthropic:1.8.0' + pluginLibsCompile 'dev.langchain4j:langchain4j-ollama:1.8.0' pluginLibsCompile 'dev.langchain4j:langchain4j-open-ai:1.8.0' } diff --git a/ai/src/main/java/org/apache/ofbiz/ai/AiFactory.java b/ai/src/main/java/org/apache/ofbiz/ai/AiFactory.java deleted file mode 100644 index 6f3493299..000000000 --- a/ai/src/main/java/org/apache/ofbiz/ai/AiFactory.java +++ /dev/null @@ -1,43 +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.ofbiz.ai; - -import dev.langchain4j.model.chat.ChatModel; - -public class AiFactory { - - private static final String MODULE = AiFactory.class.getName(); - - private static ChatModel chatModel; - - public static void setChatModel(ChatModel model) { - AiFactory.chatModel = model; - } - - public static ChatModel getChatModel() { - if (chatModel == null) { - throw new IllegalStateException("AI plugin is not initialized. Check ai.properties configuration."); - } - return chatModel; - } - - public static void destroy() { - chatModel = null; - } -} diff --git a/ai/src/main/java/org/apache/ofbiz/ai/AiWorker.java b/ai/src/main/java/org/apache/ofbiz/ai/AiWorker.java index 7de2894ec..09c863272 100644 --- a/ai/src/main/java/org/apache/ofbiz/ai/AiWorker.java +++ b/ai/src/main/java/org/apache/ofbiz/ai/AiWorker.java @@ -43,6 +43,7 @@ import dev.langchain4j.model.chat.request.json.JsonSchemaElement; import dev.langchain4j.model.chat.request.json.JsonStringSchema; +import org.apache.ofbiz.ai.container.AiContainer; import org.apache.ofbiz.base.util.Debug; import org.apache.ofbiz.base.util.GeneralException; import org.apache.ofbiz.base.util.UtilGenerics; @@ -64,9 +65,12 @@ private AiWorker() { } public static String generate(DispatchContext dctx, List> messages) throws GeneralException { + var chatModel = AiContainer.getChatModel(); + if (chatModel == null) { + return "AI service is not available. Check ai.properties configuration."; + } try { List chatMessages = toChatMessages(messages); - var chatModel = AiFactory.getChatModel(); var request = ChatRequest.builder().messages(chatMessages).build(); var response = chatModel.chat(request); return response.aiMessage().text(); @@ -79,6 +83,11 @@ public static String generate(DispatchContext dctx, public static Map generateStructured(DispatchContext dctx, List> messages, Map schema) throws GeneralException { + var chatModel = AiContainer.getChatModel(); + if (chatModel == null) { + throw new GeneralException( + "AI service is not available. Check ai.properties configuration."); + } try { List chatMessages = toChatMessages(messages); JsonObjectSchema jsonObjectSchema = buildJsonObjectSchema(schema); @@ -86,7 +95,6 @@ public static Map generateStructured(DispatchContext dctx, .name("response").rootElement(jsonObjectSchema).build(); ResponseFormat responseFormat = ResponseFormat.builder() .type(ResponseFormatType.JSON).jsonSchema(jsonSchema).build(); - var chatModel = AiFactory.getChatModel(); var request = ChatRequest.builder() .messages(chatMessages).responseFormat(responseFormat).build(); var response = chatModel.chat(request); diff --git a/ai/src/main/java/org/apache/ofbiz/ai/container/AiContainer.java b/ai/src/main/java/org/apache/ofbiz/ai/container/AiContainer.java index b2a63b500..c2155d48e 100644 --- a/ai/src/main/java/org/apache/ofbiz/ai/container/AiContainer.java +++ b/ai/src/main/java/org/apache/ofbiz/ai/container/AiContainer.java @@ -21,10 +21,11 @@ import java.time.Duration; import java.util.List; +import dev.langchain4j.model.anthropic.AnthropicChatModel; import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.ollama.OllamaChatModel; import dev.langchain4j.model.openai.OpenAiChatModel; -import org.apache.ofbiz.ai.AiFactory; import org.apache.ofbiz.base.container.Container; import org.apache.ofbiz.base.container.ContainerException; import org.apache.ofbiz.base.start.StartupCommand; @@ -36,6 +37,8 @@ public class AiContainer implements Container { private static final String MODULE = AiContainer.class.getName(); + private static ChatModel chatModel; + private String name; private String configFile; @@ -59,39 +62,68 @@ public boolean start() throws ContainerException { timeoutSecs = 60; } - if (UtilValidate.isEmpty(apiKey) || "REPLACE_WITH_YOUR_API_KEY".equals(apiKey)) { - Debug.logError("AI plugin: ai.apiKey is not configured in ai.properties", MODULE); - return false; - } - - ChatModel chatModel; - // Additional providers (anthropic, ollama native, bedrock) - // can be added here with their respective LangChain4j builders - switch (provider) { - case "openai": - default: - var builder = OpenAiChatModel.builder() - .apiKey(apiKey) - .modelName(model) - .timeout(Duration.ofSeconds(timeoutSecs)); - if (UtilValidate.isNotEmpty(baseUrl)) { - builder.baseUrl(baseUrl); - } - chatModel = builder.build(); + ChatModel chatModel = buildChatModel(provider, model, apiKey, baseUrl, timeoutSecs); + if (chatModel == null) { + Debug.logWarning("AI plugin disabled - check ai.properties", MODULE); + return true; } - - AiFactory.setChatModel(chatModel); + AiContainer.chatModel = chatModel; Debug.logInfo("AI plugin initialized: provider=" + provider + " model=" + model, MODULE); return true; } + private static ChatModel buildChatModel(String provider, String model, String apiKey, + String baseUrl, int timeoutSecs) throws ContainerException { + if ("anthropic".equals(provider)) { + if (UtilValidate.isEmpty(apiKey) || "REPLACE_WITH_YOUR_API_KEY".equals(apiKey)) { + Debug.logWarning("AI plugin: ai.apiKey is required for provider 'anthropic'", MODULE); + return null; + } + var builder = AnthropicChatModel.builder() + .apiKey(apiKey) + .modelName(model) + .timeout(Duration.ofSeconds(timeoutSecs)); + if (UtilValidate.isNotEmpty(baseUrl)) { + builder.baseUrl(baseUrl); + } + return builder.build(); + } else if ("ollama".equals(provider)) { + return OllamaChatModel.builder() + .baseUrl(UtilValidate.isNotEmpty(baseUrl) ? baseUrl : "http://localhost:11434") + .modelName(model) + .timeout(Duration.ofSeconds(timeoutSecs)) + .build(); + } else if ("openai".equals(provider)) { + if (UtilValidate.isEmpty(apiKey) || "REPLACE_WITH_YOUR_API_KEY".equals(apiKey)) { + Debug.logWarning("AI plugin: ai.apiKey is required for provider 'openai'", MODULE); + return null; + } + var builder = OpenAiChatModel.builder() + .apiKey(apiKey) + .modelName(model) + .timeout(Duration.ofSeconds(timeoutSecs)); + if (UtilValidate.isNotEmpty(baseUrl)) { + builder.baseUrl(baseUrl); + } + return builder.build(); + } else { + Debug.logWarning("AI plugin: unsupported provider '" + provider + + "'. Supported providers: openai, anthropic, ollama", MODULE); + return null; + } + } + @Override public void stop() throws ContainerException { - AiFactory.destroy(); + chatModel = null; } @Override public String getName() { return name; } + + public static ChatModel getChatModel() { + return chatModel; + } } From 0545a93f6c1486a99ae1c00b20b818d4a429d412 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Sat, 16 May 2026 15:17:19 +0530 Subject: [PATCH 15/70] [AI] Remove ai.properties and CLAUDE.md from gitignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both files are safe to track — ai.properties now uses placeholder values only, and CLAUDE.md contains no credentials. --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index 1144db3bd..19aa9488a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ .DS_Store bin/ node_modules/ -ai/config/ai.properties -ai/CLAUDE.md -ai/docs/ From 0d96cc6cd7963050e00a2601e52eaab0221a1a77 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Sat, 16 May 2026 15:20:09 +0530 Subject: [PATCH 16/70] [AI] Step 3: Add ai.properties with multi-provider documentation Document all three supported providers (openai, anthropic, ollama) with inline comments, model examples, and placeholder API key. Follows OFBiz convention of committing properties files with placeholder values. --- ai/config/ai.properties | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 ai/config/ai.properties diff --git a/ai/config/ai.properties b/ai/config/ai.properties new file mode 100644 index 000000000..bcdfb2598 --- /dev/null +++ b/ai/config/ai.properties @@ -0,0 +1,38 @@ +# AI Plugin configuration +# This file is gitignored — never commit API keys. +# +# Supported providers: openai | anthropic | ollama +# To switch provider: uncomment the desired block, comment out the others. + +# ------------------------------------------------- +# Provider: openai +# Models: gpt-4o, gpt-4o-mini, gpt-4-turbo, o1-mini +# Docs: https://platform.openai.com/docs/models +# ------------------------------------------------- +ai.provider=openai +ai.model=gpt-4o-mini +ai.apiKey=REPLACE_WITH_YOUR_API_KEY +ai.baseUrl= +ai.timeout=60 + +# ------------------------------------------------- +# Provider: anthropic +# Models: claude-opus-4-5, claude-sonnet-4-5, claude-3-5-haiku-20241022 +# Docs: https://docs.anthropic.com/en/docs/about-claude/models +# ------------------------------------------------- +#ai.provider=anthropic +#ai.model=claude-3-5-haiku-20241022 +#ai.apiKey=REPLACE_WITH_YOUR_API_KEY +#ai.baseUrl= +#ai.timeout=60 + +# ------------------------------------------------- +# Provider: ollama (local, no API key required) +# Models: llama3.2, llama3.1, mistral, gemma3 +# Docs: https://ollama.com/library +# ------------------------------------------------- +#ai.provider=ollama +#ai.model=llama3.2 +#ai.apiKey= +#ai.baseUrl=http://localhost:11434 +#ai.timeout=120 From 296857a4ffcc252d866839ac7107ac575b93f3b4 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 12:25:29 +0530 Subject: [PATCH 17/70] feat(ai): Phase 1A - remove LangChain4j, add agent value objects and interfaces --- ai/build.gradle | 4 - .../ofbiz/ai/agent/AgentDefinition.java | 74 ++++++++++++ .../apache/ofbiz/ai/agent/AiChatClient.java | 106 ++++++++++++++++++ .../apache/ofbiz/ai/agent/ProviderConfig.java | 72 ++++++++++++ .../apache/ofbiz/ai/agent/ToolDescriptor.java | 76 +++++++++++++ 5 files changed, 328 insertions(+), 4 deletions(-) create mode 100644 ai/src/main/java/org/apache/ofbiz/ai/agent/AgentDefinition.java create mode 100644 ai/src/main/java/org/apache/ofbiz/ai/agent/AiChatClient.java create mode 100644 ai/src/main/java/org/apache/ofbiz/ai/agent/ProviderConfig.java create mode 100644 ai/src/main/java/org/apache/ofbiz/ai/agent/ToolDescriptor.java diff --git a/ai/build.gradle b/ai/build.gradle index 91ae11591..02f8d4511 100644 --- a/ai/build.gradle +++ b/ai/build.gradle @@ -18,8 +18,4 @@ */ dependencies { - pluginLibsCompile 'dev.langchain4j:langchain4j:1.8.0' - pluginLibsCompile 'dev.langchain4j:langchain4j-anthropic:1.8.0' - pluginLibsCompile 'dev.langchain4j:langchain4j-ollama:1.8.0' - pluginLibsCompile 'dev.langchain4j:langchain4j-open-ai:1.8.0' } diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentDefinition.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentDefinition.java new file mode 100644 index 000000000..3e85d655a --- /dev/null +++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentDefinition.java @@ -0,0 +1,74 @@ +/******************************************************************************* + * 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.ofbiz.ai.agent; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Immutable value object describing one agent declared in agents.xml. + * The {@code modelOverride} field is nullable; a {@code null} value means the + * agent uses the default model configured on its provider. + */ +public final class AgentDefinition { + + private final String name; + private final String providerName; + private final String modelOverride; + private final int maxIterations; + private final String systemPrompt; + private final List toolAllowList; + + public AgentDefinition(String name, String providerName, String modelOverride, + int maxIterations, String systemPrompt, List toolAllowList) { + this.name = name; + this.providerName = providerName; + this.modelOverride = modelOverride; + this.maxIterations = maxIterations; + this.systemPrompt = systemPrompt; + this.toolAllowList = Collections.unmodifiableList( + new ArrayList<>(toolAllowList != null ? toolAllowList : Collections.emptyList())); + } + + public String getName() { + return name; + } + + public String getProviderName() { + return providerName; + } + + /** Returns the model override, or {@code null} to use the provider's default model. */ + public String getModelOverride() { + return modelOverride; + } + + public int getMaxIterations() { + return maxIterations; + } + + public String getSystemPrompt() { + return systemPrompt; + } + + public List getToolAllowList() { + return toolAllowList; + } +} diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/AiChatClient.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/AiChatClient.java new file mode 100644 index 000000000..61a6b2829 --- /dev/null +++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/AiChatClient.java @@ -0,0 +1,106 @@ +/******************************************************************************* + * 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.ofbiz.ai.agent; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.node.ObjectNode; + +import org.apache.ofbiz.base.util.GeneralException; + +/** + * Seam interface for the LLM HTTP transport layer. + * Implementations send a chat-completions request to the configured provider + * and return a {@link ChatResponse}. A stub implementation can be substituted + * in Phase 2 unit tests without a live network connection. + */ +public interface AiChatClient { + + /** + * Send a chat request to the LLM provider and return the parsed response. + * + * @param messages ordered list of message objects; each map must contain + * at minimum {@code "role"} and {@code "content"} keys + * @param toolSchemas list of pre-built JSON Schema {@link ObjectNode}s that + * describe the tools available to the LLM in this turn; + * may be empty but never {@code null} + * @param model model identifier to use for this request; when + * {@code null} the implementation falls back to the model + * declared on the {@code provider} + * @param provider provider configuration (base URL, API key, timeout, etc.) + * @return a non-null {@link ChatResponse} + * @throws GeneralException if the HTTP request fails or the response cannot + * be parsed + */ + ChatResponse chat(List> messages, + List toolSchemas, + String model, + ProviderConfig provider) throws GeneralException; + + /** + * Immutable value object returned by {@link AiChatClient#chat}. + * + *

When {@code finishReason} is {@code "stop"}, {@code content} is + * populated and {@code toolCalls} is {@code null}. + * When {@code finishReason} is {@code "tool_calls"}, {@code toolCalls} is + * populated and {@code content} is {@code null}. + */ + final class ChatResponse { + + private final String finishReason; + private final String content; + private final List> toolCalls; + private final int inputTokens; + private final int outputTokens; + + public ChatResponse(String finishReason, String content, + List> toolCalls, + int inputTokens, int outputTokens) { + this.finishReason = finishReason; + this.content = content; + this.toolCalls = toolCalls; + this.inputTokens = inputTokens; + this.outputTokens = outputTokens; + } + + /** Returns {@code "stop"} or {@code "tool_calls"}. */ + public String getFinishReason() { + return finishReason; + } + + /** Returns the assistant text when {@code finishReason} is {@code "stop"}; {@code null} otherwise. */ + public String getContent() { + return content; + } + + /** Returns the tool-call list when {@code finishReason} is {@code "tool_calls"}; {@code null} otherwise. */ + public List> getToolCalls() { + return toolCalls; + } + + public int getInputTokens() { + return inputTokens; + } + + public int getOutputTokens() { + return outputTokens; + } + } +} diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/ProviderConfig.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/ProviderConfig.java new file mode 100644 index 000000000..d3f2be997 --- /dev/null +++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/ProviderConfig.java @@ -0,0 +1,72 @@ +/******************************************************************************* + * 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.ofbiz.ai.agent; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Immutable value object holding configuration for one named LLM provider. + * Instances are loaded from ai.properties by the framework bootstrap layer. + */ +public final class ProviderConfig { + + private final String name; + private final String baseUrl; + private final String apiKey; + private final String model; + private final int timeoutSeconds; + private final Map extraHeaders; + + public ProviderConfig(String name, String baseUrl, String apiKey, + String model, int timeoutSeconds, Map extraHeaders) { + this.name = name; + this.baseUrl = baseUrl; + this.apiKey = apiKey; + this.model = model; + this.timeoutSeconds = timeoutSeconds; + this.extraHeaders = Collections.unmodifiableMap( + new LinkedHashMap<>(extraHeaders != null ? extraHeaders : Collections.emptyMap())); + } + + public String getName() { + return name; + } + + public String getBaseUrl() { + return baseUrl; + } + + public String getApiKey() { + return apiKey; + } + + public String getModel() { + return model; + } + + public int getTimeoutSeconds() { + return timeoutSeconds; + } + + public Map getExtraHeaders() { + return extraHeaders; + } +} diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/ToolDescriptor.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/ToolDescriptor.java new file mode 100644 index 000000000..3a73cde75 --- /dev/null +++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/ToolDescriptor.java @@ -0,0 +1,76 @@ +/******************************************************************************* + * 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.ofbiz.ai.agent; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Immutable value object describing one tool declared in agents.xml. + * The {@code jsonSchema} field holds the pre-built JSON Schema ObjectNode + * that is sent to the LLM to describe the tool's callable parameters. + */ +public final class ToolDescriptor { + + private final String name; + private final String serviceName; + private final String description; + private final Set hiddenParams; + private final String requiredPermission; + private final ObjectNode jsonSchema; + + public ToolDescriptor(String name, String serviceName, String description, + Set hiddenParams, String requiredPermission, ObjectNode jsonSchema) { + this.name = name; + this.serviceName = serviceName; + this.description = description; + this.hiddenParams = Collections.unmodifiableSet( + new LinkedHashSet<>(hiddenParams != null ? hiddenParams : Collections.emptySet())); + this.requiredPermission = requiredPermission; + this.jsonSchema = jsonSchema; + } + + public String getName() { + return name; + } + + public String getServiceName() { + return serviceName; + } + + public String getDescription() { + return description; + } + + public Set getHiddenParams() { + return hiddenParams; + } + + /** Returns the required permission string, or {@code null} if none is required. */ + public String getRequiredPermission() { + return requiredPermission; + } + + public ObjectNode getJsonSchema() { + return jsonSchema; + } +} From 01687fd67e2b92bd68edb2ea63bbca1a423f3094 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 12:32:08 +0530 Subject: [PATCH 18/70] fix(ai): make ChatResponse.toolCalls unmodifiable for consistency --- .../main/java/org/apache/ofbiz/ai/agent/AiChatClient.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/AiChatClient.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/AiChatClient.java index 61a6b2829..7c35b5730 100644 --- a/ai/src/main/java/org/apache/ofbiz/ai/agent/AiChatClient.java +++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/AiChatClient.java @@ -18,6 +18,8 @@ *******************************************************************************/ package org.apache.ofbiz.ai.agent; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -75,7 +77,9 @@ public ChatResponse(String finishReason, String content, int inputTokens, int outputTokens) { this.finishReason = finishReason; this.content = content; - this.toolCalls = toolCalls; + this.toolCalls = toolCalls != null + ? Collections.unmodifiableList(new ArrayList<>(toolCalls)) + : null; this.inputTokens = inputTokens; this.outputTokens = outputTokens; } From a0839f8d79d158c514e1627e0f25abf352660d7a Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 12:38:09 +0530 Subject: [PATCH 19/70] feat(ai): Phase 1B - add ProviderRegistry, ToolCatalog, AgentRegistry, AiHttpClient ProviderRegistry reads named-provider blocks from ai.properties and builds ProviderConfig instances. ToolCatalog scans component ai/*.tools.xml files and builds ToolDescriptor instances with JSON Schema from ModelService params. AgentRegistry scans component ai/*.agent.xml files and builds AgentDefinition instances, validating provider and tool references at startup. AiHttpClient implements AiChatClient using java.net.http.HttpClient and Jackson for OpenAI-compatible chat/completions requests. --- .../apache/ofbiz/ai/agent/AgentRegistry.java | 254 ++++++++++++++ .../apache/ofbiz/ai/agent/AiHttpClient.java | 226 +++++++++++++ .../ofbiz/ai/agent/ProviderRegistry.java | 160 +++++++++ .../apache/ofbiz/ai/agent/ToolCatalog.java | 311 ++++++++++++++++++ 4 files changed, 951 insertions(+) create mode 100644 ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRegistry.java create mode 100644 ai/src/main/java/org/apache/ofbiz/ai/agent/AiHttpClient.java create mode 100644 ai/src/main/java/org/apache/ofbiz/ai/agent/ProviderRegistry.java create mode 100644 ai/src/main/java/org/apache/ofbiz/ai/agent/ToolCatalog.java diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRegistry.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRegistry.java new file mode 100644 index 000000000..5594e9331 --- /dev/null +++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRegistry.java @@ -0,0 +1,254 @@ +/******************************************************************************* + * 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.ofbiz.ai.agent; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +import org.apache.ofbiz.base.component.ComponentConfig; +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.UtilValidate; +import org.apache.ofbiz.service.DispatchContext; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +/** + * Scans all installed OFBiz components for {@code ai/*.agent.xml} files and + * builds an in-memory index of {@link AgentDefinition} instances. + * + *

Each agent must reference a provider that exists in the supplied + * {@link ProviderRegistry}, and each tool in its allow-list must exist in the + * supplied {@link ToolCatalog}. Missing references cause an + * {@link IllegalStateException} to surface at startup. + */ +public final class AgentRegistry { + + private static final String MODULE = AgentRegistry.class.getName(); + private static final int DEFAULT_MAX_ITERATIONS = 6; + + private final Map agents; + + /** + * Constructs the registry by scanning every OFBiz component's {@code ai/} + * directory for files whose name ends with {@code .agent.xml}. + * + * @param toolCatalog catalog used to validate tool references + * @param providerRegistry registry used to validate provider references + * @param dctx the dispatch context (unused directly, retained for + * symmetry with other registry constructors) + */ + public AgentRegistry(ToolCatalog toolCatalog, ProviderRegistry providerRegistry, + DispatchContext dctx) { + Map loaded = new LinkedHashMap<>(); + + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(false); + + for (ComponentConfig cc : ComponentConfig.getAllComponents()) { + String aiDirPath = cc.rootLocation().toString() + File.separator + "ai"; + File aiDir = new File(aiDirPath); + if (!aiDir.isDirectory()) { + continue; + } + + File[] agentFiles = aiDir.listFiles( + f -> f.isFile() && f.getName().endsWith(".agent.xml")); + if (agentFiles == null || agentFiles.length == 0) { + continue; + } + + for (File agentFile : agentFiles) { + parseAgentsFile(agentFile, dbf, toolCatalog, providerRegistry, + cc.rootLocation(), loaded); + } + } + + this.agents = Collections.unmodifiableMap(loaded); + Debug.logInfo("AgentRegistry loaded " + this.agents.size() + " agent(s).", MODULE); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private void parseAgentsFile(File file, DocumentBuilderFactory dbf, + ToolCatalog toolCatalog, ProviderRegistry providerRegistry, + Path componentRoot, Map loaded) { + Document doc; + try { + DocumentBuilder db = dbf.newDocumentBuilder(); + doc = db.parse(file); + } catch (ParserConfigurationException | SAXException | IOException e) { + Debug.logWarning("AgentRegistry: could not parse '" + file.getAbsolutePath() + + "': " + e.getMessage(), MODULE); + return; + } + + doc.getDocumentElement().normalize(); + NodeList agentNodes = doc.getElementsByTagName("agent"); + + for (int i = 0; i < agentNodes.getLength(); i++) { + Element agentEl = (Element) agentNodes.item(i); + parseAgent(agentEl, file.getAbsolutePath(), toolCatalog, providerRegistry, + componentRoot, loaded); + } + } + + private void parseAgent(Element agentEl, String sourceFile, + ToolCatalog toolCatalog, ProviderRegistry providerRegistry, + Path componentRoot, Map loaded) { + + String name = agentEl.getAttribute("name").trim(); + String providerName = agentEl.getAttribute("provider").trim(); + String modelOverride = agentEl.getAttribute("model").trim(); + String maxIterStr = agentEl.getAttribute("max-iterations").trim(); + + if (UtilValidate.isEmpty(name)) { + Debug.logWarning("AgentRegistry: in '" + sourceFile + + "' has no name attribute; skipping.", MODULE); + return; + } + if (loaded.containsKey(name)) { + throw new IllegalStateException("AgentRegistry: duplicate agent name '" + + name + "' found in '" + sourceFile + "'."); + } + if (UtilValidate.isEmpty(providerName)) { + throw new IllegalStateException("AgentRegistry: agent '" + name + + "' in '" + sourceFile + "' has no provider attribute."); + } + if (providerRegistry.getProvider(providerName) == null) { + throw new IllegalStateException("AgentRegistry: agent '" + name + + "' references unknown provider '" + providerName + "'."); + } + + if (UtilValidate.isEmpty(modelOverride)) { + modelOverride = null; + } + + int maxIterations = DEFAULT_MAX_ITERATIONS; + if (UtilValidate.isNotEmpty(maxIterStr)) { + try { + maxIterations = Integer.parseInt(maxIterStr); + } catch (NumberFormatException e) { + Debug.logWarning("AgentRegistry: agent '" + name + + "' has invalid max-iterations '" + maxIterStr + + "'; using default " + DEFAULT_MAX_ITERATIONS + ".", MODULE); + } + } + + // System prompt: inline CDATA or external file + String systemPrompt = resolveSystemPrompt(agentEl, name, componentRoot, sourceFile); + + // Tool allow-list + List toolAllowList = new ArrayList<>(); + NodeList toolNodes = agentEl.getElementsByTagName("tool"); + for (int i = 0; i < toolNodes.getLength(); i++) { + Element toolEl = (Element) toolNodes.item(i); + String toolName = toolEl.getAttribute("name").trim(); + if (UtilValidate.isEmpty(toolName)) { + Debug.logWarning("AgentRegistry: agent '" + name + + "' has a element with no name; skipping entry.", MODULE); + continue; + } + if (!toolCatalog.hasTool(toolName)) { + throw new IllegalStateException("AgentRegistry: agent '" + name + + "' references unknown tool '" + toolName + "'."); + } + toolAllowList.add(toolName); + } + + loaded.put(name, new AgentDefinition( + name, providerName, modelOverride, maxIterations, systemPrompt, toolAllowList)); + Debug.logInfo("AgentRegistry: registered agent '" + name + + "' (provider=" + providerName + ", tools=" + toolAllowList.size() + ").", MODULE); + } + + /** + * Resolves the system prompt for an agent element. If a + * {@code } child element is present the file it + * points to (relative to the component root) is read; otherwise the text + * content of the {@code } element is used. + */ + private String resolveSystemPrompt(Element agentEl, String agentName, + Path componentRoot, String sourceFile) { + + NodeList locationNodes = agentEl.getElementsByTagName("system-prompt-location"); + if (locationNodes.getLength() > 0) { + String location = locationNodes.item(0).getTextContent(); + if (UtilValidate.isNotEmpty(location)) { + location = location.trim(); + Path promptPath = componentRoot.resolve(Paths.get(location)).normalize(); + try { + return new String(Files.readAllBytes(promptPath)).trim(); + } catch (IOException e) { + throw new IllegalStateException("AgentRegistry: agent '" + agentName + + "' could not read system-prompt-location '" + + promptPath + "': " + e.getMessage(), e); + } + } + } + + NodeList promptNodes = agentEl.getElementsByTagName("system-prompt"); + if (promptNodes.getLength() > 0) { + String text = promptNodes.item(0).getTextContent(); + return text != null ? text.trim() : ""; + } + + return ""; + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** + * Returns the {@link AgentDefinition} for the given name, or {@code null} + * if no such agent is registered. + * + * @param name the agent name + * @return the agent definition, or {@code null} + */ + public AgentDefinition getAgent(String name) { + return agents.get(name); + } + + /** + * Returns an unmodifiable view of all registered agent names. + * + * @return set of agent names + */ + public Set getAgentNames() { + return agents.keySet(); + } +} diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/AiHttpClient.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/AiHttpClient.java new file mode 100644 index 000000000..68593ab2e --- /dev/null +++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/AiHttpClient.java @@ -0,0 +1,226 @@ +/******************************************************************************* + * 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.ofbiz.ai.agent; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.GeneralException; +import org.apache.ofbiz.base.util.UtilValidate; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Production implementation of {@link AiChatClient} that sends + * OpenAI-compatible {@code /chat/completions} requests using + * {@link java.net.http.HttpClient} (Java 11+) and Jackson for + * JSON serialisation and parsing. + * + *

A single instance is created at container startup and shared across all + * agent invocations; the underlying {@link HttpClient} is thread-safe. + */ +public final class AiHttpClient implements AiChatClient { + + private static final String MODULE = AiHttpClient.class.getName(); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final int BODY_SNIPPET_MAX_CHARS = 500; + + private final HttpClient httpClient; + + /** + * Constructs a new client with a 10-second TCP connect timeout. + * Per-request read timeouts are taken from {@link ProviderConfig#getTimeoutSeconds()}. + */ + public AiHttpClient() { + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + /** {@inheritDoc} */ + @Override + public ChatResponse chat(List> messages, + List toolSchemas, + String model, + ProviderConfig provider) throws GeneralException { + + String requestBody = buildRequestBody(messages, toolSchemas, model, provider); + + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(provider.getBaseUrl() + "/chat/completions")) + .timeout(Duration.ofSeconds(provider.getTimeoutSeconds())) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + provider.getApiKey()) + .POST(HttpRequest.BodyPublishers.ofString(requestBody)); + + for (Map.Entry header : provider.getExtraHeaders().entrySet()) { + requestBuilder.header(header.getKey(), header.getValue()); + } + + HttpRequest request = requestBuilder.build(); + + String responseBody; + int statusCode; + try { + HttpResponse response = httpClient.send( + request, HttpResponse.BodyHandlers.ofString()); + statusCode = response.statusCode(); + responseBody = response.body(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new GeneralException("AiHttpClient: request interrupted.", e); + } catch (IOException e) { + throw new GeneralException("AiHttpClient: I/O error during HTTP request: " + + e.getMessage(), e); + } + + if (statusCode < 200 || statusCode >= 300) { + String snippet = UtilValidate.isNotEmpty(responseBody) + ? responseBody.substring(0, Math.min(responseBody.length(), BODY_SNIPPET_MAX_CHARS)) + : "(empty body)"; + throw new GeneralException("AiHttpClient: provider returned HTTP " + statusCode + + ": " + snippet); + } + + return parseResponse(responseBody); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private String buildRequestBody(List> messages, + List toolSchemas, + String model, + ProviderConfig provider) throws GeneralException { + + ObjectNode root = MAPPER.createObjectNode(); + root.put("model", UtilValidate.isNotEmpty(model) ? model : provider.getModel()); + + ArrayNode msgArray = MAPPER.createArrayNode(); + for (Map msg : messages) { + ObjectNode msgNode = MAPPER.createObjectNode(); + for (Map.Entry entry : msg.entrySet()) { + Object val = entry.getValue(); + if (val instanceof String) { + msgNode.put(entry.getKey(), (String) val); + } else if (val instanceof List) { + // tool_calls or content arrays — serialise via MAPPER + try { + msgNode.set(entry.getKey(), + MAPPER.valueToTree(val)); + } catch (IllegalArgumentException e) { + Debug.logWarning("AiHttpClient: could not serialise message field '" + + entry.getKey() + "': " + e.getMessage(), MODULE); + } + } else if (val != null) { + msgNode.putPOJO(entry.getKey(), val); + } + } + msgArray.add(msgNode); + } + root.set("messages", msgArray); + + if (toolSchemas != null && !toolSchemas.isEmpty()) { + ArrayNode toolsArray = MAPPER.createArrayNode(); + for (ObjectNode schema : toolSchemas) { + ObjectNode toolNode = MAPPER.createObjectNode(); + toolNode.put("type", "function"); + toolNode.set("function", schema); + toolsArray.add(toolNode); + } + root.set("tools", toolsArray); + root.put("tool_choice", "auto"); + } + + try { + return MAPPER.writeValueAsString(root); + } catch (JsonProcessingException e) { + throw new GeneralException( + "AiHttpClient: failed to serialise request body: " + e.getMessage(), e); + } + } + + private ChatResponse parseResponse(String responseBody) throws GeneralException { + JsonNode root; + try { + root = MAPPER.readTree(responseBody); + } catch (JsonProcessingException e) { + throw new GeneralException( + "AiHttpClient: failed to parse response JSON: " + e.getMessage(), e); + } + + JsonNode choices = root.path("choices"); + if (!choices.isArray() || choices.size() == 0) { + throw new GeneralException( + "AiHttpClient: response has no choices array."); + } + + JsonNode firstChoice = choices.get(0); + String finishReason = firstChoice.path("finish_reason").asText("stop"); + JsonNode messageNode = firstChoice.path("message"); + + String content = null; + JsonNode contentNode = messageNode.path("content"); + if (!contentNode.isMissingNode() && !contentNode.isNull()) { + content = contentNode.asText(); + } + + List> toolCalls = null; + JsonNode toolCallsNode = messageNode.path("tool_calls"); + if (toolCallsNode.isArray() && toolCallsNode.size() > 0) { + toolCalls = new ArrayList<>(); + for (JsonNode tc : toolCallsNode) { + toolCalls.add(toolCallToMap(tc)); + } + } + + int inputTokens = root.path("usage").path("prompt_tokens").asInt(0); + int outputTokens = root.path("usage").path("completion_tokens").asInt(0); + + return new ChatResponse(finishReason, content, toolCalls, inputTokens, outputTokens); + } + + private Map toolCallToMap(JsonNode tc) { + Map map = new LinkedHashMap<>(); + map.put("id", tc.path("id").asText()); + map.put("type", tc.path("type").asText("function")); + + Map function = new LinkedHashMap<>(); + JsonNode fnNode = tc.path("function"); + function.put("name", fnNode.path("name").asText()); + function.put("arguments", fnNode.path("arguments").asText()); + + map.put("function", function); + return map; + } +} diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/ProviderRegistry.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/ProviderRegistry.java new file mode 100644 index 000000000..eed830998 --- /dev/null +++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/ProviderRegistry.java @@ -0,0 +1,160 @@ +/******************************************************************************* + * 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.ofbiz.ai.agent; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.TreeSet; + +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.UtilProperties; +import org.apache.ofbiz.base.util.UtilValidate; +import org.apache.ofbiz.service.DispatchContext; + +/** + * Loads named LLM provider blocks from {@code ai.properties} at container startup. + * + *

Each provider block follows the naming convention: + * {@code ai.provider..} where {@code } is an arbitrary + * identifier such as {@code openai-default} or {@code anthropic-default}. + * Required fields are {@code baseUrl}, {@code apiKey}, and {@code model}. + * Optional fields are {@code timeout} (default 60) and {@code extraHeaders} + * (comma-separated {@code key:value} pairs). + */ +public final class ProviderRegistry { + + private static final String MODULE = ProviderRegistry.class.getName(); + private static final String PROVIDER_PREFIX = "ai.provider."; + private static final String PLACEHOLDER_KEY = "REPLACE_WITH_YOUR_API_KEY"; + private static final int DEFAULT_TIMEOUT = 60; + + private final Map providers; + + /** + * Constructs the registry by scanning {@code ai.properties} for all + * named-provider blocks. Invalid or unconfigured providers are skipped + * with a warning; the registry is never {@code null} even if no providers + * are loaded. + * + * @param dctx the dispatch context (unused directly, retained for symmetry + * with other registry constructors) + */ + public ProviderRegistry(DispatchContext dctx) { + Map loaded = new LinkedHashMap<>(); + + Properties props = UtilProperties.getProperties("ai"); + if (props == null) { + Debug.logWarning("ProviderRegistry: ai.properties not found; no providers loaded.", MODULE); + this.providers = Collections.emptyMap(); + return; + } + + // Collect distinct provider names from keys like ai.provider.. + Set names = new TreeSet<>(); + for (String key : props.stringPropertyNames()) { + if (key.startsWith(PROVIDER_PREFIX)) { + String remainder = key.substring(PROVIDER_PREFIX.length()); + int dot = remainder.indexOf('.'); + if (dot > 0) { + names.add(remainder.substring(0, dot)); + } + } + } + + for (String name : names) { + String pfx = PROVIDER_PREFIX + name + "."; + String baseUrl = props.getProperty(pfx + "baseUrl", "").trim(); + String apiKey = props.getProperty(pfx + "apiKey", "").trim(); + String model = props.getProperty(pfx + "model", "").trim(); + String timeoutStr = props.getProperty(pfx + "timeout", "").trim(); + String extraHeadersRaw = props.getProperty(pfx + "extraHeaders", "").trim(); + + if (UtilValidate.isEmpty(baseUrl)) { + Debug.logWarning("ProviderRegistry: provider '" + name + + "' has no baseUrl; skipping.", MODULE); + continue; + } + if (UtilValidate.isEmpty(apiKey) || PLACEHOLDER_KEY.equals(apiKey)) { + Debug.logWarning("ProviderRegistry: provider '" + name + + "' has no valid apiKey; skipping.", MODULE); + continue; + } + if (UtilValidate.isEmpty(model)) { + Debug.logWarning("ProviderRegistry: provider '" + name + + "' has no model; skipping.", MODULE); + continue; + } + + int timeout = DEFAULT_TIMEOUT; + if (UtilValidate.isNotEmpty(timeoutStr)) { + try { + timeout = Integer.parseInt(timeoutStr); + } catch (NumberFormatException e) { + Debug.logWarning("ProviderRegistry: provider '" + name + + "' has invalid timeout '" + timeoutStr + + "'; using default " + DEFAULT_TIMEOUT + "s.", MODULE); + } + } + + Map extraHeaders = new LinkedHashMap<>(); + if (UtilValidate.isNotEmpty(extraHeadersRaw)) { + for (String pair : extraHeadersRaw.split(",")) { + pair = pair.trim(); + int colon = pair.indexOf(':'); + if (colon > 0) { + String hKey = pair.substring(0, colon).trim(); + String hVal = pair.substring(colon + 1).trim(); + if (UtilValidate.isNotEmpty(hKey)) { + extraHeaders.put(hKey, hVal); + } + } + } + } + + loaded.put(name, new ProviderConfig(name, baseUrl, apiKey, model, timeout, extraHeaders)); + Debug.logInfo("ProviderRegistry: loaded provider '" + name + "' (model=" + model + ").", MODULE); + } + + this.providers = Collections.unmodifiableMap(loaded); + Debug.logInfo("ProviderRegistry: " + this.providers.size() + " provider(s) configured.", MODULE); + } + + /** + * Returns the {@link ProviderConfig} for the given name, or {@code null} + * if no such provider is configured. + * + * @param name the provider name (e.g. {@code "openai-default"}) + * @return the provider config, or {@code null} + */ + public ProviderConfig getProvider(String name) { + return providers.get(name); + } + + /** + * Returns an unmodifiable view of all configured provider names. + * + * @return set of provider names + */ + public Set getProviderNames() { + return providers.keySet(); + } +} diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/ToolCatalog.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/ToolCatalog.java new file mode 100644 index 000000000..7fbca291a --- /dev/null +++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/ToolCatalog.java @@ -0,0 +1,311 @@ +/******************************************************************************* + * 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.ofbiz.ai.agent; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +import org.apache.ofbiz.base.component.ComponentConfig; +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.UtilValidate; +import org.apache.ofbiz.service.DispatchContext; +import org.apache.ofbiz.service.ModelParam; +import org.apache.ofbiz.service.ModelService; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Scans all installed OFBiz components for {@code ai/*.tools.xml} files and + * builds an in-memory index of {@link ToolDescriptor} instances. + * + *

The catalog is built once at container startup; it is not reloaded while + * the server is running. + */ +public final class ToolCatalog { + + private static final String MODULE = ToolCatalog.class.getName(); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final Map tools; + + /** + * Constructs the catalog by scanning every OFBiz component's {@code ai/} + * directory for files whose name ends with {@code .tools.xml}. + * + * @param dctx the dispatch context used to validate service references + */ + public ToolCatalog(DispatchContext dctx) { + Map loaded = new LinkedHashMap<>(); + int componentCount = 0; + + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(false); + + for (ComponentConfig cc : ComponentConfig.getAllComponents()) { + String aiDirPath = cc.rootLocation().toString() + File.separator + "ai"; + File aiDir = new File(aiDirPath); + if (!aiDir.isDirectory()) { + continue; + } + + File[] toolFiles = aiDir.listFiles( + f -> f.isFile() && f.getName().endsWith(".tools.xml")); + if (toolFiles == null || toolFiles.length == 0) { + continue; + } + + componentCount++; + for (File toolFile : toolFiles) { + parseToolsFile(toolFile, dbf, dctx, loaded); + } + } + + this.tools = Collections.unmodifiableMap(loaded); + Debug.logInfo("ToolCatalog loaded " + this.tools.size() + + " tool(s) from " + componentCount + " component(s).", MODULE); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private void parseToolsFile(File file, DocumentBuilderFactory dbf, + DispatchContext dctx, Map loaded) { + Document doc; + try { + DocumentBuilder db = dbf.newDocumentBuilder(); + doc = db.parse(file); + } catch (ParserConfigurationException | SAXException | IOException e) { + Debug.logWarning("ToolCatalog: could not parse '" + file.getAbsolutePath() + + "': " + e.getMessage(), MODULE); + return; + } + + doc.getDocumentElement().normalize(); + NodeList toolNodes = doc.getElementsByTagName("tool"); + + for (int i = 0; i < toolNodes.getLength(); i++) { + Element toolEl = (Element) toolNodes.item(i); + parseTool(toolEl, file.getAbsolutePath(), dctx, loaded); + } + } + + private void parseTool(Element toolEl, String sourceFile, + DispatchContext dctx, Map loaded) { + + String name = toolEl.getAttribute("name").trim(); + String serviceName = toolEl.getAttribute("service").trim(); + String requiredPermission = toolEl.getAttribute("required-permission").trim(); + if (UtilValidate.isEmpty(requiredPermission)) { + requiredPermission = null; + } + + if (UtilValidate.isEmpty(name)) { + Debug.logWarning("ToolCatalog: in '" + sourceFile + + "' has no name attribute; skipping.", MODULE); + return; + } + if (loaded.containsKey(name)) { + throw new IllegalStateException("ToolCatalog: duplicate tool name '" + + name + "' found in '" + sourceFile + "'."); + } + if (UtilValidate.isEmpty(serviceName)) { + throw new IllegalStateException("ToolCatalog: tool '" + name + + "' in '" + sourceFile + "' has no service attribute."); + } + + ModelService modelService; + try { + modelService = dctx.getModelService(serviceName); + } catch (Exception e) { + throw new IllegalStateException("ToolCatalog: tool '" + name + + "' references unknown service '" + serviceName + "'.", e); + } + + // Description from child, optionally appended with + String description = getElementText(toolEl, "description"); + String example = getElementText(toolEl, "example"); + if (UtilValidate.isNotEmpty(example)) { + description = UtilValidate.isEmpty(description) + ? example + : description + " Example: " + example; + } + + // Hidden params from

Both methods locate an available provider from {@link AiContainer}, + * delegate to {@link AiHttpClient} for the actual HTTP call, and return + * the parsed result. No LangChain4j types are used here. + */ public final class AiWorker { private static final String MODULE = AiWorker.class.getName(); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final Map> TYPE_BUILDERS = new HashMap<>(); - static { - TYPE_BUILDERS.put("string", JsonStringSchema::new); - TYPE_BUILDERS.put("number", JsonNumberSchema::new); - TYPE_BUILDERS.put("integer", JsonIntegerSchema::new); - TYPE_BUILDERS.put("boolean", JsonBooleanSchema::new); - } + private static final String DEFAULT_PROVIDER = "openai-default"; private AiWorker() { } + /** + * Sends a chat request to the default configured provider and returns the + * assistant's text response. + * + *

Provider lookup order: + *

    + *
  1. Provider named {@value #DEFAULT_PROVIDER} in {@code ai.properties}.
  2. + *
  3. First available provider if {@value #DEFAULT_PROVIDER} is not configured.
  4. + *
+ * + * @param dctx the dispatch context (unused directly, retained for API symmetry) + * @param messages ordered list of role/content message maps + * @return the assistant's text, or a human-readable error string if the AI + * service is not configured + * @throws GeneralException if the HTTP request fails or the response cannot be parsed + */ public static String generate(DispatchContext dctx, List> messages) throws GeneralException { - var chatModel = AiContainer.getChatModel(); - if (chatModel == null) { + ProviderConfig provider = resolveProvider(); + if (provider == null) { return "AI service is not available. Check ai.properties configuration."; } - try { - List chatMessages = toChatMessages(messages); - var request = ChatRequest.builder().messages(chatMessages).build(); - var response = chatModel.chat(request); - return response.aiMessage().text(); - } catch (Exception e) { - Debug.logError(e, "AI generate failed", MODULE); - throw new GeneralException("AI generate failed: " + e.getMessage(), e); - } + AiChatClient client = new AiHttpClient(); + AiChatClient.ChatResponse response = client.chat(messages, + Collections.emptyList(), null, provider); + return response.getContent(); } + /** + * Sends a chat request instructing the LLM to respond with JSON matching the + * supplied schema, then parses and returns that JSON as a {@link Map}. + * + *

The schema instruction is appended as an additional system message so + * that providers without native structured-output support can still fulfil + * the request via prompt guidance. + * + * @param dctx the dispatch context (unused directly, retained for API symmetry) + * @param messages ordered list of role/content message maps + * @param schema the expected response schema expressed as a {@code Map} whose + * values are JSON-serialisable descriptors + * @return the parsed JSON object returned by the LLM + * @throws GeneralException if the AI service is not configured, the HTTP + * request fails, or the response is not valid JSON + */ public static Map generateStructured(DispatchContext dctx, List> messages, Map schema) throws GeneralException { - var chatModel = AiContainer.getChatModel(); - if (chatModel == null) { + ProviderConfig provider = resolveProvider(); + if (provider == null) { throw new GeneralException( "AI service is not available. Check ai.properties configuration."); } + + // Build schema instruction message + String schemaJson; try { - List chatMessages = toChatMessages(messages); - JsonObjectSchema jsonObjectSchema = buildJsonObjectSchema(schema); - JsonSchema jsonSchema = JsonSchema.builder() - .name("response").rootElement(jsonObjectSchema).build(); - ResponseFormat responseFormat = ResponseFormat.builder() - .type(ResponseFormatType.JSON).jsonSchema(jsonSchema).build(); - var request = ChatRequest.builder() - .messages(chatMessages).responseFormat(responseFormat).build(); - var response = chatModel.chat(request); - return OBJECT_MAPPER.readValue(response.aiMessage().text(), - new TypeReference>() { }); + schemaJson = OBJECT_MAPPER.writeValueAsString(schema); } catch (Exception e) { - Debug.logError(e, "AI generateStructured failed", MODULE); - throw new GeneralException("AI generateStructured failed: " + e.getMessage(), e); + Debug.logWarning("AiWorker: could not serialise schema map: " + e.getMessage(), MODULE); + schemaJson = schema.toString(); } - } - private static List toChatMessages(List> messages) { - List chatMessages = new ArrayList<>(); - for (Map msg : messages) { - String role = (String) msg.get("role"); - String content = (String) msg.get("content"); - if ("system".equals(role)) { - chatMessages.add(SystemMessage.from(content)); - } else if ("assistant".equals(role)) { - chatMessages.add(AiMessage.from(content)); - } else { - chatMessages.add(UserMessage.from(content)); - } + List> augmentedMessages = new ArrayList<>(messages); + Map schemaInstruction = new java.util.LinkedHashMap<>(); + schemaInstruction.put("role", "system"); + schemaInstruction.put("content", + "Respond with a JSON object matching this schema: " + schemaJson); + augmentedMessages.add(schemaInstruction); + + AiChatClient client = new AiHttpClient(); + AiChatClient.ChatResponse response = client.chat(augmentedMessages, + Collections.emptyList(), null, provider); + + String content = response.getContent(); + if (UtilValidate.isEmpty(content)) { + throw new GeneralException("AiWorker: LLM returned empty content for generateStructured."); } - return chatMessages; - } - private static JsonObjectSchema buildJsonObjectSchema(Map schemaMap) { - JsonObjectSchema.Builder builder = JsonObjectSchema.builder(); - for (Map.Entry entry : schemaMap.entrySet()) { - builder.addProperty(entry.getKey(), buildSchemaElement(entry.getValue())); + try { + return OBJECT_MAPPER.readValue(content, + new TypeReference>() { }); + } catch (Exception e) { + Debug.logError(e, "AiWorker: generateStructured failed to parse LLM response as JSON", MODULE); + throw new GeneralException( + "AI generateStructured failed: response was not valid JSON. " + e.getMessage(), e); } - return builder.build(); } - private static JsonSchemaElement buildSchemaElement(Object descriptor) { - if (descriptor instanceof String type) { - if ("array".equals(type)) return JsonArraySchema.builder().build(); - if ("object".equals(type)) return JsonObjectSchema.builder().build(); - return TYPE_BUILDERS.getOrDefault(type, JsonStringSchema::new).get(); + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Resolves the provider to use for simple generate calls. + * Returns {@code null} if no providers are configured. + */ + private static ProviderConfig resolveProvider() { + if (AiContainer.getProviderRegistry() == null) { + return null; } - if (descriptor instanceof Map) { - Map descMap = UtilGenerics.cast(descriptor); - String type = (String) descMap.get("type"); - if ("array".equals(type)) { - JsonArraySchema.Builder ab = JsonArraySchema.builder(); - if (descMap.containsKey("items")) ab.items(buildSchemaElement(descMap.get("items"))); - return ab.build(); - } - if ("object".equals(type)) { - Object props = descMap.get("properties"); - if (props instanceof Map) return buildJsonObjectSchema(UtilGenerics.cast(props)); - return JsonObjectSchema.builder().build(); - } - if (type != null) return TYPE_BUILDERS.getOrDefault(type, JsonStringSchema::new).get(); + ProviderConfig provider = AiContainer.getProviderRegistry().getProvider(DEFAULT_PROVIDER); + if (provider == null && !AiContainer.getProviderRegistry().getProviderNames().isEmpty()) { + String firstName = AiContainer.getProviderRegistry().getProviderNames().iterator().next(); + provider = AiContainer.getProviderRegistry().getProvider(firstName); + Debug.logInfo("AiWorker: '" + DEFAULT_PROVIDER + + "' not configured; falling back to provider '" + firstName + "'.", MODULE); } - return new JsonStringSchema(); + return provider; } } diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java new file mode 100644 index 000000000..cc281ecbc --- /dev/null +++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java @@ -0,0 +1,334 @@ +/******************************************************************************* + * 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.ofbiz.ai.agent; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import org.apache.ofbiz.ai.container.AiContainer; +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.GeneralException; +import org.apache.ofbiz.entity.GenericValue; +import org.apache.ofbiz.service.DispatchContext; +import org.apache.ofbiz.service.ServiceUtil; + +/** + * Executes the agentic loop for a single {@code agentRun} invocation. + * + *

The runner loads the agent definition and provider configuration from + * {@link AiContainer}, builds the initial message list, then iterates up to + * {@code maxIterations} times: calling the LLM, executing any requested tool + * calls via {@link DispatchContext#getDispatcher()}, and feeding the results + * back into the conversation. The loop exits when the model returns a + * {@code "stop"} finish reason, the iteration cap is reached, or the model + * returns an unexpected finish reason. + * + *

A package-private {@link #setChatClient(AiChatClient)} setter is provided + * as a test seam so that Phase 2 unit tests can substitute a stub without a + * live network connection. + */ +public final class AgentRunner { + + private static final String MODULE = AgentRunner.class.getName(); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final int TOOL_RESULT_MAX_CHARS = 8000; + + private final String agentName; + private final String userMessage; + private final GenericValue userLogin; + private final DispatchContext dctx; + + // Non-final to allow Phase 2 test seam injection + private AiChatClient chatClient = new AiHttpClient(); + + /** + * Constructs a runner for one agent invocation. + * + * @param agentName name of the agent declared in an {@code *.agent.xml} file + * @param userMessage the user's input message + * @param userLogin the authenticated user for service invocations + * @param dctx the dispatch context used to run OFBiz services as tools + */ + public AgentRunner(String agentName, String userMessage, + GenericValue userLogin, DispatchContext dctx) { + this.agentName = agentName; + this.userMessage = userMessage; + this.userLogin = userLogin; + this.dctx = dctx; + } + + /** + * Package-private setter that replaces the default {@link AiHttpClient} with + * a custom implementation. Intended only for unit tests. + * + * @param client the {@link AiChatClient} to use for this run + */ + void setChatClient(AiChatClient client) { + this.chatClient = client; + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** + * Executes the agent loop and returns a {@link RunResult} when complete. + * + * @return the result containing the final assistant message, stop reason, and + * iteration count + * @throws GeneralException if the agent or provider is not configured, a + * required tool is missing, or the LLM request fails + */ + public RunResult run() throws GeneralException { + + // 1. Load agent definition + AgentDefinition agent = AiContainer.getAgentRegistry().getAgent(agentName); + if (agent == null) { + throw new GeneralException("Unknown agent: " + agentName); + } + + // 2. Load provider config + ProviderConfig provider = AiContainer.getProviderRegistry().getProvider(agent.getProviderName()); + if (provider == null) { + throw new GeneralException("Unconfigured provider: " + agent.getProviderName()); + } + + // 3. Resolve tool allow-list + ToolCatalog toolCatalog = AiContainer.getToolCatalog(); + Map allowedTools = new LinkedHashMap<>(); + for (String toolName : agent.getToolAllowList()) { + ToolDescriptor descriptor = toolCatalog.getTool(toolName); + if (descriptor == null) { + throw new GeneralException("Agent '" + agentName + + "' references unknown tool '" + toolName + "'"); + } + allowedTools.put(toolName, descriptor); + } + + // 4. Build tool schemas list + List toolSchemas = new ArrayList<>(); + for (ToolDescriptor descriptor : allowedTools.values()) { + toolSchemas.add(descriptor.getJsonSchema()); + } + + // 5. Build initial messages list + List> messages = new ArrayList<>(); + Map systemMsg = new LinkedHashMap<>(); + systemMsg.put("role", "system"); + systemMsg.put("content", agent.getSystemPrompt()); + messages.add(systemMsg); + + Map userMsg = new LinkedHashMap<>(); + userMsg.put("role", "user"); + userMsg.put("content", userMessage); + messages.add(userMsg); + + // 6. Agent loop + String modelToUse = agent.getModelOverride(); + int maxIterations = agent.getMaxIterations(); + AiChatClient.ChatResponse lastResponse = null; + + for (int iteration = 0; iteration < maxIterations; iteration++) { + AiChatClient.ChatResponse response = chatClient.chat( + Collections.unmodifiableList(messages), toolSchemas, modelToUse, provider); + lastResponse = response; + + String finishReason = response.getFinishReason(); + + if ("stop".equals(finishReason)) { + return new RunResult(response.getContent(), "stop", iteration + 1); + } + + if ("tool_calls".equals(finishReason)) { + List> toolCalls = response.getToolCalls(); + + // Append the assistant message with tool_calls BEFORE tool results + Map assistantMsg = new LinkedHashMap<>(); + assistantMsg.put("role", "assistant"); + assistantMsg.put("content", null); + assistantMsg.put("tool_calls", toolCalls); + messages.add(assistantMsg); + + // Execute each tool call and append its result message + for (Map toolCall : toolCalls) { + String toolCallId = (String) toolCall.get("id"); + + @SuppressWarnings("unchecked") + Map functionMap = (Map) toolCall.get("function"); + String toolName = (String) functionMap.get("name"); + String toolArgsJson = (String) functionMap.get("arguments"); + + ToolDescriptor descriptor = allowedTools.get(toolName); + if (descriptor == null) { + Debug.logWarning("AgentRunner: tool '" + toolName + + "' called by LLM is not in agent allow-list; skipping.", MODULE); + continue; + } + + String resultJson = invokeToolService(descriptor, toolArgsJson); + + Map toolResultMsg = new LinkedHashMap<>(); + toolResultMsg.put("role", "tool"); + toolResultMsg.put("tool_call_id", toolCallId); + toolResultMsg.put("content", resultJson); + messages.add(toolResultMsg); + } + + } else { + // Unexpected finish reason — exit loop + Debug.logWarning("AgentRunner: unexpected finish_reason '" + finishReason + + "' for agent '" + agentName + "'; stopping loop.", MODULE); + String content = lastResponse.getContent(); + return new RunResult(content, finishReason, iteration + 1); + } + } + + // 7. Loop exhausted without stop + String lastContent = lastResponse != null ? lastResponse.getContent() : null; + return new RunResult(lastContent, "max_iterations", maxIterations); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Invokes the OFBiz service backing a tool and serialises the result to JSON. + * + * @param descriptor the tool descriptor + * @param toolArgsJson the JSON string of arguments from the LLM + * @return serialised service result (capped at {@value #TOOL_RESULT_MAX_CHARS} chars) + */ + private String invokeToolService(ToolDescriptor descriptor, String toolArgsJson) { + // Parse tool arguments JSON string to Map + Map parsedArgs; + try { + parsedArgs = MAPPER.readValue(toolArgsJson, + new TypeReference>() { }); + } catch (JsonProcessingException e) { + Debug.logWarning("AgentRunner: could not parse tool args JSON for tool '" + + descriptor.getName() + "': " + e.getMessage(), MODULE); + parsedArgs = new HashMap<>(); + } + + // Build service context — omit hidden params, always include userLogin + Map ctx = new HashMap<>(); + ctx.put("userLogin", userLogin); + for (Map.Entry entry : parsedArgs.entrySet()) { + if (!descriptor.getHiddenParams().contains(entry.getKey())) { + ctx.put(entry.getKey(), entry.getValue()); + } + } + + // Invoke the service + Map serviceResult; + try { + serviceResult = dctx.getDispatcher().runSync(descriptor.getServiceName(), ctx); + } catch (Exception e) { + Debug.logError(e, "AgentRunner: service invocation failed for tool '" + + descriptor.getName() + "'", MODULE); + return "Error invoking service: " + e.getMessage(); + } + + // If service returned an error, surface that as the tool result + if (ServiceUtil.isError(serviceResult)) { + return ServiceUtil.getErrorMessage(serviceResult); + } + + // Serialise result map to JSON string + try { + String resultJson = MAPPER.writeValueAsString(serviceResult); + if (resultJson.length() > TOOL_RESULT_MAX_CHARS) { + resultJson = resultJson.substring(0, TOOL_RESULT_MAX_CHARS) + "...[truncated]"; + } + return resultJson; + } catch (JsonProcessingException e) { + Debug.logWarning("AgentRunner: could not serialise result for tool '" + + descriptor.getName() + "': " + e.getMessage(), MODULE); + return "Error serialising result: " + e.getMessage(); + } + } + + // --------------------------------------------------------------------------- + // Result type + // --------------------------------------------------------------------------- + + /** + * Immutable result returned by {@link AgentRunner#run()}. + */ + public static final class RunResult { + + private final String assistantMessage; + private final String stopReason; + private final int iterationsUsed; + + /** + * Constructs a run result. + * + * @param assistantMessage the final text response from the assistant, or + * {@code null} if the loop ended without a stop + * @param stopReason one of {@code "stop"}, {@code "max_iterations"}, + * or an unexpected finish reason string + * @param iterationsUsed number of loop iterations consumed + */ + public RunResult(String assistantMessage, String stopReason, int iterationsUsed) { + this.assistantMessage = assistantMessage; + this.stopReason = stopReason; + this.iterationsUsed = iterationsUsed; + } + + /** + * Returns the final assistant text, or {@code null} when the loop ended + * without a {@code "stop"} finish reason. + * + * @return assistant message text + */ + public String getAssistantMessage() { + return assistantMessage; + } + + /** + * Returns the reason the loop stopped: {@code "stop"}, {@code "max_iterations"}, + * or the raw finish reason string from the provider. + * + * @return stop reason + */ + public String getStopReason() { + return stopReason; + } + + /** + * Returns the number of loop iterations that were executed. + * + * @return iterations used + */ + public int getIterationsUsed() { + return iterationsUsed; + } + } +} diff --git a/ai/src/main/java/org/apache/ofbiz/ai/container/AiContainer.java b/ai/src/main/java/org/apache/ofbiz/ai/container/AiContainer.java index c2155d48e..d769a386f 100644 --- a/ai/src/main/java/org/apache/ofbiz/ai/container/AiContainer.java +++ b/ai/src/main/java/org/apache/ofbiz/ai/container/AiContainer.java @@ -18,104 +18,81 @@ *******************************************************************************/ package org.apache.ofbiz.ai.container; -import java.time.Duration; import java.util.List; -import dev.langchain4j.model.anthropic.AnthropicChatModel; -import dev.langchain4j.model.chat.ChatModel; -import dev.langchain4j.model.ollama.OllamaChatModel; -import dev.langchain4j.model.openai.OpenAiChatModel; - +import org.apache.ofbiz.ai.agent.AgentRegistry; +import org.apache.ofbiz.ai.agent.ProviderRegistry; +import org.apache.ofbiz.ai.agent.ToolCatalog; import org.apache.ofbiz.base.container.Container; import org.apache.ofbiz.base.container.ContainerException; import org.apache.ofbiz.base.start.StartupCommand; import org.apache.ofbiz.base.util.Debug; -import org.apache.ofbiz.base.util.UtilProperties; -import org.apache.ofbiz.base.util.UtilValidate; +import org.apache.ofbiz.entity.Delegator; +import org.apache.ofbiz.entity.DelegatorFactory; +import org.apache.ofbiz.service.LocalDispatcher; +import org.apache.ofbiz.service.ServiceContainer; +/** + * OFBiz container that bootstraps the AI agent framework at server startup. + * + *

On {@link #start()} this container: + *

    + *
  1. Obtains the default OFBiz {@link LocalDispatcher}.
  2. + *
  3. Constructs a {@link ProviderRegistry} from {@code ai.properties}.
  4. + *
  5. Constructs a {@link ToolCatalog} by scanning component {@code ai/} directories.
  6. + *
  7. Constructs an {@link AgentRegistry} by scanning component {@code ai/} directories.
  8. + *
+ * + *

The three registries are held as static fields so that service Groovy scripts and + * {@link org.apache.ofbiz.ai.agent.AgentRunner} can access them via the static getters + * without requiring a container reference. + */ public class AiContainer implements Container { private static final String MODULE = AiContainer.class.getName(); - private static ChatModel chatModel; + private static ToolCatalog toolCatalog; + private static AgentRegistry agentRegistry; + private static ProviderRegistry providerRegistry; private String name; - private String configFile; @Override - public void init(List ofbizCommands, String name, String configFile) throws ContainerException { + public void init(List ofbizCommands, String name, String configFile) + throws ContainerException { this.name = name; - this.configFile = configFile; } @Override public boolean start() throws ContainerException { - String provider = UtilProperties.getPropertyValue("ai", "ai.provider", "openai"); - String model = UtilProperties.getPropertyValue("ai", "ai.model", "gpt-4o-mini"); - String apiKey = UtilProperties.getPropertyValue("ai", "ai.apiKey"); - String baseUrl = UtilProperties.getPropertyValue("ai", "ai.baseUrl", ""); - int timeoutSecs; - try { - timeoutSecs = Integer.parseInt( - UtilProperties.getPropertyValue("ai", "ai.timeout", "60")); - } catch (NumberFormatException e) { - timeoutSecs = 60; + Delegator delegator = DelegatorFactory.getDelegator("default"); + if (delegator == null) { + Debug.logWarning("AiContainer: delegator not available, AI plugin disabled.", MODULE); + return true; } - - ChatModel chatModel = buildChatModel(provider, model, apiKey, baseUrl, timeoutSecs); - if (chatModel == null) { - Debug.logWarning("AI plugin disabled - check ai.properties", MODULE); + LocalDispatcher dispatcher = ServiceContainer.getLocalDispatcher("default", delegator); + if (dispatcher == null) { + Debug.logWarning("AiContainer: dispatcher not available, AI plugin disabled.", MODULE); return true; } - AiContainer.chatModel = chatModel; - Debug.logInfo("AI plugin initialized: provider=" + provider + " model=" + model, MODULE); - return true; - } - - private static ChatModel buildChatModel(String provider, String model, String apiKey, - String baseUrl, int timeoutSecs) throws ContainerException { - if ("anthropic".equals(provider)) { - if (UtilValidate.isEmpty(apiKey) || "REPLACE_WITH_YOUR_API_KEY".equals(apiKey)) { - Debug.logWarning("AI plugin: ai.apiKey is required for provider 'anthropic'", MODULE); - return null; - } - var builder = AnthropicChatModel.builder() - .apiKey(apiKey) - .modelName(model) - .timeout(Duration.ofSeconds(timeoutSecs)); - if (UtilValidate.isNotEmpty(baseUrl)) { - builder.baseUrl(baseUrl); - } - return builder.build(); - } else if ("ollama".equals(provider)) { - return OllamaChatModel.builder() - .baseUrl(UtilValidate.isNotEmpty(baseUrl) ? baseUrl : "http://localhost:11434") - .modelName(model) - .timeout(Duration.ofSeconds(timeoutSecs)) - .build(); - } else if ("openai".equals(provider)) { - if (UtilValidate.isEmpty(apiKey) || "REPLACE_WITH_YOUR_API_KEY".equals(apiKey)) { - Debug.logWarning("AI plugin: ai.apiKey is required for provider 'openai'", MODULE); - return null; - } - var builder = OpenAiChatModel.builder() - .apiKey(apiKey) - .modelName(model) - .timeout(Duration.ofSeconds(timeoutSecs)); - if (UtilValidate.isNotEmpty(baseUrl)) { - builder.baseUrl(baseUrl); - } - return builder.build(); - } else { - Debug.logWarning("AI plugin: unsupported provider '" + provider - + "'. Supported providers: openai, anthropic, ollama", MODULE); - return null; + var dctx = dispatcher.getDispatchContext(); + try { + providerRegistry = new ProviderRegistry(dctx); + toolCatalog = new ToolCatalog(dctx); + agentRegistry = new AgentRegistry(toolCatalog, providerRegistry, dctx); + } catch (Exception e) { + throw new ContainerException("AiContainer failed to start: " + e.getMessage(), e); } + Debug.logInfo("AiContainer started: providers=" + providerRegistry.getProviderNames() + + " agents=" + agentRegistry.getAgentNames(), MODULE); + return true; } @Override public void stop() throws ContainerException { - chatModel = null; + toolCatalog = null; + agentRegistry = null; + providerRegistry = null; } @Override @@ -123,7 +100,33 @@ public String getName() { return name; } - public static ChatModel getChatModel() { - return chatModel; + /** + * Returns the {@link ToolCatalog} built at startup, or {@code null} if the + * container has not been started yet. + * + * @return tool catalog + */ + public static ToolCatalog getToolCatalog() { + return toolCatalog; + } + + /** + * Returns the {@link AgentRegistry} built at startup, or {@code null} if the + * container has not been started yet. + * + * @return agent registry + */ + public static AgentRegistry getAgentRegistry() { + return agentRegistry; + } + + /** + * Returns the {@link ProviderRegistry} built at startup, or {@code null} if + * the container has not been started yet. + * + * @return provider registry + */ + public static ProviderRegistry getProviderRegistry() { + return providerRegistry; } } From d36761256f1476dd88be4d97e3571a0d92b67bfd Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 12:53:16 +0530 Subject: [PATCH 22/70] fix(ai): defensive null guards in AgentRunner tool dispatch --- ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java index cc281ecbc..0f54cdcc7 100644 --- a/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java +++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java @@ -180,6 +180,10 @@ public RunResult run() throws GeneralException { @SuppressWarnings("unchecked") Map functionMap = (Map) toolCall.get("function"); + if (functionMap == null) { + Debug.logWarning("AgentRunner: tool call missing 'function' field; skipping.", MODULE); + continue; + } String toolName = (String) functionMap.get("name"); String toolArgsJson = (String) functionMap.get("arguments"); @@ -203,7 +207,7 @@ public RunResult run() throws GeneralException { // Unexpected finish reason — exit loop Debug.logWarning("AgentRunner: unexpected finish_reason '" + finishReason + "' for agent '" + agentName + "'; stopping loop.", MODULE); - String content = lastResponse.getContent(); + String content = lastResponse != null ? lastResponse.getContent() : null; return new RunResult(content, finishReason, iteration + 1); } } From c06b8666098f7667860617531371517c54fe3251 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 12:57:08 +0530 Subject: [PATCH 23/70] feat(ai): Phase 1D - agentRun service, sample XML, checkstyle fixes --- ai/ai/sample.agent.xml | 33 ++++++++++ ai/ai/sample.tools.xml | 26 ++++++++ ai/servicedef/services.xml | 11 ++++ .../org/apache/ofbiz/ai/AiAgentServices.java | 61 +++++++++++++++++++ .../apache/ofbiz/ai/agent/ToolCatalog.java | 40 ++++++------ 5 files changed, 151 insertions(+), 20 deletions(-) create mode 100644 ai/ai/sample.agent.xml create mode 100644 ai/ai/sample.tools.xml create mode 100644 ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java diff --git a/ai/ai/sample.agent.xml b/ai/ai/sample.agent.xml new file mode 100644 index 000000000..d6294e59a --- /dev/null +++ b/ai/ai/sample.agent.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + diff --git a/ai/ai/sample.tools.xml b/ai/ai/sample.tools.xml new file mode 100644 index 000000000..25efa4485 --- /dev/null +++ b/ai/ai/sample.tools.xml @@ -0,0 +1,26 @@ + + + + + + Run the AI smoke test to verify the AI plugin is working. + + diff --git a/ai/servicedef/services.xml b/ai/servicedef/services.xml index f98c7e374..6a4f019de 100644 --- a/ai/servicedef/services.xml +++ b/ai/servicedef/services.xml @@ -48,4 +48,15 @@ under the License. Smoke test for generateStructured — expects Map with word key + + Run a named AI agent with a user message and return the assistant response + + + + + + + diff --git a/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java b/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java new file mode 100644 index 000000000..1e25301da --- /dev/null +++ b/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java @@ -0,0 +1,61 @@ +/******************************************************************************* + * 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.ofbiz.ai; + +import java.util.Map; + +import org.apache.ofbiz.ai.agent.AgentRunner; +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.GeneralException; +import org.apache.ofbiz.base.util.UtilValidate; +import org.apache.ofbiz.entity.GenericValue; +import org.apache.ofbiz.service.DispatchContext; +import org.apache.ofbiz.service.ServiceUtil; + +public class AiAgentServices { + + private static final String MODULE = AiAgentServices.class.getName(); + + public static Map agentRun(DispatchContext dctx, + Map context) { + String agentName = (String) context.get("agentName"); + String userMessage = (String) context.get("userMessage"); + GenericValue userLogin = (GenericValue) context.get("userLogin"); + + if (UtilValidate.isEmpty(agentName)) { + return ServiceUtil.returnError("agentName is required"); + } + if (UtilValidate.isEmpty(userMessage)) { + return ServiceUtil.returnError("userMessage is required"); + } + + try { + AgentRunner runner = new AgentRunner(agentName, userMessage, userLogin, dctx); + AgentRunner.RunResult result = runner.run(); + Map serviceResult = ServiceUtil.returnSuccess(); + serviceResult.put("assistantMessage", result.getAssistantMessage()); + serviceResult.put("stopReason", result.getStopReason()); + serviceResult.put("iterationsUsed", result.getIterationsUsed()); + return serviceResult; + } catch (GeneralException e) { + Debug.logError(e, "agentRun failed: " + e.getMessage(), MODULE); + return ServiceUtil.returnError(e.getMessage()); + } + } +} diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/ToolCatalog.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/ToolCatalog.java index e45badd2d..5d30e737e 100644 --- a/ai/src/main/java/org/apache/ofbiz/ai/agent/ToolCatalog.java +++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/ToolCatalog.java @@ -257,26 +257,26 @@ private String ofbizTypeToJsonType(String ofbizType) { return "string"; } switch (ofbizType) { - case "String": - case "java.lang.String": - return "string"; - case "Integer": - case "java.lang.Integer": - case "Long": - case "java.lang.Long": - return "integer"; - case "Double": - case "java.lang.Double": - case "Float": - case "java.lang.Float": - case "BigDecimal": - case "java.math.BigDecimal": - return "number"; - case "Boolean": - case "java.lang.Boolean": - return "boolean"; - default: - return "string"; + case "String": + case "java.lang.String": + return "string"; + case "Integer": + case "java.lang.Integer": + case "Long": + case "java.lang.Long": + return "integer"; + case "Double": + case "java.lang.Double": + case "Float": + case "java.lang.Float": + case "BigDecimal": + case "java.math.BigDecimal": + return "number"; + case "Boolean": + case "java.lang.Boolean": + return "boolean"; + default: + return "string"; } } From 796b6012ea8c85aa0f70272f671a2e6b8d88a018 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 13:05:25 +0530 Subject: [PATCH 24/70] feat(ai): Phase 2 - observability: AiAgentRun/AiAgentToolCall entities, persistence, offline tests - Add entitydef/AiAgentEntities.xml with AiAgentRun and AiAgentToolCall entities - Add data/AiAgentSeedData.xml with StatusType and StatusItem seed rows - Register entity model and seed data in ofbiz-component.xml - Add persistence to AgentRunner: create run record before loop, persist tool calls inline, update run record with token counts and status after loop completes; all delegator calls wrapped in try-catch so persistence failures never abort runs - Add package-private test constructor to AgentRunner that bypasses AiContainer - Add MockAiChatClient scripted FIFO test double for AiChatClient - Add AgentRunnerTest with three offline tests: stop reason, max_iterations cap, and tool result truncation --- ai/data/AiAgentSeedData.xml | 47 ++++ ai/entitydef/AiAgentEntities.xml | 61 +++++ ai/ofbiz-component.xml | 5 + .../apache/ofbiz/ai/agent/AgentRunner.java | 208 +++++++++++++++--- .../ofbiz/ai/agent/MockAiChatClient.java | 73 ++++++ ai/testdef/AgentRunnerTest.java | 133 +++++++++++ 6 files changed, 500 insertions(+), 27 deletions(-) create mode 100644 ai/data/AiAgentSeedData.xml create mode 100644 ai/entitydef/AiAgentEntities.xml create mode 100644 ai/src/main/java/org/apache/ofbiz/ai/agent/MockAiChatClient.java create mode 100644 ai/testdef/AgentRunnerTest.java diff --git a/ai/data/AiAgentSeedData.xml b/ai/data/AiAgentSeedData.xml new file mode 100644 index 000000000..099a09558 --- /dev/null +++ b/ai/data/AiAgentSeedData.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/ai/entitydef/AiAgentEntities.xml b/ai/entitydef/AiAgentEntities.xml new file mode 100644 index 000000000..f176b3874 --- /dev/null +++ b/ai/entitydef/AiAgentEntities.xml @@ -0,0 +1,61 @@ + + + + + AI Agent Entity Model + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ai/ofbiz-component.xml b/ai/ofbiz-component.xml index 6f4a3539d..c78ece952 100644 --- a/ai/ofbiz-component.xml +++ b/ai/ofbiz-component.xml @@ -25,6 +25,11 @@ under the License. + + + diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java index 0f54cdcc7..8edae78fd 100644 --- a/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java +++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java @@ -33,6 +33,9 @@ import org.apache.ofbiz.ai.container.AiContainer; import org.apache.ofbiz.base.util.Debug; import org.apache.ofbiz.base.util.GeneralException; +import org.apache.ofbiz.base.util.UtilDateTime; +import org.apache.ofbiz.entity.Delegator; +import org.apache.ofbiz.entity.GenericEntityException; import org.apache.ofbiz.entity.GenericValue; import org.apache.ofbiz.service.DispatchContext; import org.apache.ofbiz.service.ServiceUtil; @@ -66,6 +69,11 @@ public final class AgentRunner { // Non-final to allow Phase 2 test seam injection private AiChatClient chatClient = new AiHttpClient(); + // Package-private fields used only by the test constructor (null in production) + private AgentDefinition testAgentDef; + private ProviderConfig testProvider; + private Map testTools; + /** * Constructs a runner for one agent invocation. * @@ -82,6 +90,30 @@ public AgentRunner(String agentName, String userMessage, this.dctx = dctx; } + /** + * Package-private constructor for unit tests — bypasses {@link AiContainer} registries. + * Pass {@code null} for {@code dctx} when the test tool allow-list is empty and + * {@link #invokeToolService} will never be called. + * + * @param agentDef agent definition to use instead of registry lookup + * @param provider provider config to use instead of registry lookup + * @param toolDescriptors list of tools available to this agent + * @param userMessage the user's input message + * @param userLogin authenticated user (may be {@code null} in tests) + * @param dctx dispatch context (may be {@code null} when no tools are invoked) + */ + AgentRunner(AgentDefinition agentDef, ProviderConfig provider, + List toolDescriptors, + String userMessage, GenericValue userLogin, DispatchContext dctx) { + this.agentName = agentDef.getName(); + this.userMessage = userMessage; + this.userLogin = userLogin; + this.dctx = dctx; + this.testAgentDef = agentDef; + this.testProvider = provider; + this.testTools = buildToolMap(toolDescriptors); + } + /** * Package-private setter that replaces the default {@link AiHttpClient} with * a custom implementation. Intended only for unit tests. @@ -106,28 +138,37 @@ void setChatClient(AiChatClient client) { */ public RunResult run() throws GeneralException { - // 1. Load agent definition - AgentDefinition agent = AiContainer.getAgentRegistry().getAgent(agentName); + // 1. Load agent definition — use test seam if available + AgentDefinition agent = testAgentDef != null + ? testAgentDef + : AiContainer.getAgentRegistry().getAgent(agentName); if (agent == null) { throw new GeneralException("Unknown agent: " + agentName); } - // 2. Load provider config - ProviderConfig provider = AiContainer.getProviderRegistry().getProvider(agent.getProviderName()); + // 2. Load provider config — use test seam if available + ProviderConfig provider = testProvider != null + ? testProvider + : AiContainer.getProviderRegistry().getProvider(agent.getProviderName()); if (provider == null) { throw new GeneralException("Unconfigured provider: " + agent.getProviderName()); } - // 3. Resolve tool allow-list - ToolCatalog toolCatalog = AiContainer.getToolCatalog(); - Map allowedTools = new LinkedHashMap<>(); - for (String toolName : agent.getToolAllowList()) { - ToolDescriptor descriptor = toolCatalog.getTool(toolName); - if (descriptor == null) { - throw new GeneralException("Agent '" + agentName - + "' references unknown tool '" + toolName + "'"); + // 3. Resolve tool allow-list — use test seam if available + Map allowedTools; + if (testTools != null) { + allowedTools = testTools; + } else { + ToolCatalog toolCatalog = AiContainer.getToolCatalog(); + allowedTools = new LinkedHashMap<>(); + for (String toolName : agent.getToolAllowList()) { + ToolDescriptor descriptor = toolCatalog.getTool(toolName); + if (descriptor == null) { + throw new GeneralException("Agent '" + agentName + + "' references unknown tool '" + toolName + "'"); + } + allowedTools.put(toolName, descriptor); } - allowedTools.put(toolName, descriptor); } // 4. Build tool schemas list @@ -148,20 +189,46 @@ public RunResult run() throws GeneralException { userMsg.put("content", userMessage); messages.add(userMsg); - // 6. Agent loop + // 6. Persistence — create run record (skip when delegator is unavailable, e.g. unit tests) + Delegator delegator = dctx != null ? dctx.getDelegator() : null; + String runId = null; + GenericValue runRecord = null; + if (delegator != null) { + runId = delegator.getNextSeqId("AiAgentRun"); + runRecord = delegator.makeValue("AiAgentRun"); + runRecord.set("runId", runId); + runRecord.set("agentName", agentName); + runRecord.set("userLoginId", userLogin != null ? userLogin.getString("userLoginId") : null); + runRecord.set("startedAt", UtilDateTime.nowTimestamp()); + runRecord.set("userMessage", userMessage); + runRecord.set("statusId", "AI_RUN_STARTED"); + try { + delegator.create(runRecord); + } catch (GenericEntityException e) { + Debug.logError(e, "AgentRunner: failed to create AiAgentRun record", MODULE); + } + } + + // 7. Agent loop String modelToUse = agent.getModelOverride(); int maxIterations = agent.getMaxIterations(); AiChatClient.ChatResponse lastResponse = null; + long totalInputTokens = 0L; + long totalOutputTokens = 0L; + RunResult loopResult = null; for (int iteration = 0; iteration < maxIterations; iteration++) { AiChatClient.ChatResponse response = chatClient.chat( Collections.unmodifiableList(messages), toolSchemas, modelToUse, provider); lastResponse = response; + totalInputTokens += response.getInputTokens(); + totalOutputTokens += response.getOutputTokens(); String finishReason = response.getFinishReason(); if ("stop".equals(finishReason)) { - return new RunResult(response.getContent(), "stop", iteration + 1); + loopResult = new RunResult(response.getContent(), "stop", iteration + 1); + break; } if ("tool_calls".equals(finishReason)) { @@ -194,7 +261,7 @@ public RunResult run() throws GeneralException { continue; } - String resultJson = invokeToolService(descriptor, toolArgsJson); + String resultJson = invokeToolService(descriptor, toolArgsJson, runId, delegator); Map toolResultMsg = new LinkedHashMap<>(); toolResultMsg.put("role", "tool"); @@ -207,14 +274,36 @@ public RunResult run() throws GeneralException { // Unexpected finish reason — exit loop Debug.logWarning("AgentRunner: unexpected finish_reason '" + finishReason + "' for agent '" + agentName + "'; stopping loop.", MODULE); - String content = lastResponse != null ? lastResponse.getContent() : null; - return new RunResult(content, finishReason, iteration + 1); + String content = lastResponse.getContent(); + loopResult = new RunResult(content, finishReason, iteration + 1); + break; + } + } + + // 8. Loop exhausted without stop + if (loopResult == null) { + String lastContent = lastResponse != null ? lastResponse.getContent() : null; + loopResult = new RunResult(lastContent, "max_iterations", maxIterations); + } + + // 9. Persistence — update run record with completion data + if (delegator != null && runRecord != null) { + runRecord.set("endedAt", UtilDateTime.nowTimestamp()); + runRecord.set("assistantMessage", loopResult.getAssistantMessage()); + runRecord.set("iterationsUsed", (long) loopResult.getIterationsUsed()); + runRecord.set("inputTokens", totalInputTokens); + runRecord.set("outputTokens", totalOutputTokens); + boolean failed = "max_iterations".equals(loopResult.getStopReason()) + || (!"stop".equals(loopResult.getStopReason())); + runRecord.set("statusId", failed ? "AI_RUN_FAILED" : "AI_RUN_COMPLETED"); + try { + runRecord.store(); + } catch (GenericEntityException e) { + Debug.logError(e, "AgentRunner: failed to update AiAgentRun record for runId=" + runId, MODULE); } } - // 7. Loop exhausted without stop - String lastContent = lastResponse != null ? lastResponse.getContent() : null; - return new RunResult(lastContent, "max_iterations", maxIterations); + return loopResult; } // --------------------------------------------------------------------------- @@ -223,12 +312,16 @@ public RunResult run() throws GeneralException { /** * Invokes the OFBiz service backing a tool and serialises the result to JSON. + * Persists an {@code AiAgentToolCall} row when {@code delegator} is non-null. * - * @param descriptor the tool descriptor + * @param descriptor the tool descriptor * @param toolArgsJson the JSON string of arguments from the LLM + * @param runId the parent run identifier (may be {@code null} in tests) + * @param delegator the entity delegator for persistence (may be {@code null} in tests) * @return serialised service result (capped at {@value #TOOL_RESULT_MAX_CHARS} chars) */ - private String invokeToolService(ToolDescriptor descriptor, String toolArgsJson) { + private String invokeToolService(ToolDescriptor descriptor, String toolArgsJson, + String runId, Delegator delegator) { // Parse tool arguments JSON string to Map Map parsedArgs; try { @@ -251,30 +344,91 @@ private String invokeToolService(ToolDescriptor descriptor, String toolArgsJson) // Invoke the service Map serviceResult; + boolean callFailed = false; + String resultJson; try { serviceResult = dctx.getDispatcher().runSync(descriptor.getServiceName(), ctx); } catch (Exception e) { Debug.logError(e, "AgentRunner: service invocation failed for tool '" + descriptor.getName() + "'", MODULE); + callFailed = true; + persistToolCall(delegator, runId, descriptor.getName(), toolArgsJson, + "Error invoking service: " + e.getMessage(), callFailed); return "Error invoking service: " + e.getMessage(); } // If service returned an error, surface that as the tool result if (ServiceUtil.isError(serviceResult)) { - return ServiceUtil.getErrorMessage(serviceResult); + callFailed = true; + String errorMsg = ServiceUtil.getErrorMessage(serviceResult); + persistToolCall(delegator, runId, descriptor.getName(), toolArgsJson, errorMsg, callFailed); + return errorMsg; } // Serialise result map to JSON string try { - String resultJson = MAPPER.writeValueAsString(serviceResult); + resultJson = MAPPER.writeValueAsString(serviceResult); if (resultJson.length() > TOOL_RESULT_MAX_CHARS) { resultJson = resultJson.substring(0, TOOL_RESULT_MAX_CHARS) + "...[truncated]"; } - return resultJson; } catch (JsonProcessingException e) { Debug.logWarning("AgentRunner: could not serialise result for tool '" + descriptor.getName() + "': " + e.getMessage(), MODULE); - return "Error serialising result: " + e.getMessage(); + callFailed = true; + resultJson = "Error serialising result: " + e.getMessage(); + } + + persistToolCall(delegator, runId, descriptor.getName(), toolArgsJson, resultJson, callFailed); + return resultJson; + } + + /** + * Builds a {@link Map} from tool name to {@link ToolDescriptor} from a list. + * Used by the package-private test constructor. + * + * @param descriptors list of tool descriptors + * @return ordered map keyed by tool name + */ + private static Map buildToolMap(List descriptors) { + Map map = new LinkedHashMap<>(); + if (descriptors != null) { + for (ToolDescriptor d : descriptors) { + map.put(d.getName(), d); + } + } + return map; + } + + /** + * Persists one {@code AiAgentToolCall} row. Errors are logged but never re-thrown + * so that a persistence failure cannot abort a completed LLM interaction. + * + * @param delegator entity delegator (no-op when {@code null}) + * @param runId parent run identifier + * @param toolName name of the tool that was called + * @param callArguments raw JSON arguments string from the LLM + * @param callResult serialised result (or error message) + * @param callFailed whether the tool invocation failed + */ + private void persistToolCall(Delegator delegator, String runId, String toolName, + String callArguments, String callResult, boolean callFailed) { + if (delegator == null) { + return; + } + try { + String callId = delegator.getNextSeqId("AiAgentToolCall"); + GenericValue callRecord = delegator.makeValue("AiAgentToolCall"); + callRecord.set("callId", callId); + callRecord.set("runId", runId); + callRecord.set("toolName", toolName); + callRecord.set("callArguments", callArguments); + callRecord.set("callResult", callResult); + callRecord.set("calledAt", UtilDateTime.nowTimestamp()); + callRecord.set("statusId", callFailed ? "AI_TOOL_FAILED" : "AI_TOOL_COMPLETED"); + delegator.create(callRecord); + } catch (GenericEntityException e) { + Debug.logError(e, "AgentRunner: failed to persist AiAgentToolCall for tool '" + + toolName + "' in run " + runId, MODULE); } } diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/MockAiChatClient.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/MockAiChatClient.java new file mode 100644 index 000000000..55475c822 --- /dev/null +++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/MockAiChatClient.java @@ -0,0 +1,73 @@ +/******************************************************************************* + * 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.ofbiz.ai.agent; + +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Queue; + +import com.fasterxml.jackson.databind.node.ObjectNode; + +import org.apache.ofbiz.base.util.GeneralException; + +/** + * Scripted test double for {@link AiChatClient}. + * + *

Responses are consumed in FIFO order. An {@link IllegalStateException} is + * thrown when {@link #chat} is called after all scripted responses have been consumed. + * Use {@link #isExhausted()} to assert that all expected calls were made. + */ +public class MockAiChatClient implements AiChatClient { + + private static final String MODULE = MockAiChatClient.class.getName(); + + private final Queue responses; + + /** + * Constructs a mock with one or more scripted responses. + * + * @param responses responses to return in FIFO order + */ + public MockAiChatClient(AiChatClient.ChatResponse... responses) { + this.responses = new ArrayDeque<>(Arrays.asList(responses)); + } + + @Override + public AiChatClient.ChatResponse chat(List> messages, + List toolSchemas, String model, ProviderConfig provider) + throws GeneralException { + AiChatClient.ChatResponse next = responses.poll(); + if (next == null) { + throw new IllegalStateException("MockAiChatClient: script exhausted — " + + "more chat() calls than scripted responses"); + } + return next; + } + + /** + * Returns {@code true} when all scripted responses have been consumed. + * + * @return {@code true} if no more scripted responses remain + */ + public boolean isExhausted() { + return responses.isEmpty(); + } +} diff --git a/ai/testdef/AgentRunnerTest.java b/ai/testdef/AgentRunnerTest.java new file mode 100644 index 000000000..915f359b6 --- /dev/null +++ b/ai/testdef/AgentRunnerTest.java @@ -0,0 +1,133 @@ +/******************************************************************************* + * 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.ofbiz.ai.agent; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Offline unit tests for {@link AgentRunner}. + * + *

Runs without a network connection, no API key, and no OFBiz container. + * Execute via: {@code java AgentRunnerTest} (after compilation). + */ +public class AgentRunnerTest { + + public static void main(String[] args) throws Exception { + testStopFinishReason(); + testMaxIterations(); + testToolResultTruncation(); + System.out.println("AgentRunnerTest: all tests passed."); + } + + // ----------------------------------------------------------------------- + // Test 1: LLM returns "stop" immediately — loop runs once, returns assistant message + // ----------------------------------------------------------------------- + private static void testStopFinishReason() throws Exception { + AiChatClient.ChatResponse stopResponse = new AiChatClient.ChatResponse( + "stop", "Hello from mock!", null, 10, 5); + + AgentDefinition agentDef = new AgentDefinition( + "TestAgent", "openai-default", null, 4, "You are a test.", Collections.emptyList()); + ProviderConfig provider = new ProviderConfig( + "openai-default", "https://api.openai.com/v1", "test-key", + "gpt-4o-mini", 30, Collections.emptyMap()); + + MockAiChatClient mock = new MockAiChatClient(stopResponse); + AgentRunner runner = new AgentRunner(agentDef, provider, + Collections.emptyList(), "Hello", null, null); + runner.setChatClient(mock); + + AgentRunner.RunResult result = runner.run(); + assert "Hello from mock!".equals(result.getAssistantMessage()) + : "Expected assistant message 'Hello from mock!' but got: " + result.getAssistantMessage(); + assert "stop".equals(result.getStopReason()) + : "Expected stop reason 'stop' but got: " + result.getStopReason(); + assert result.getIterationsUsed() == 1 + : "Expected 1 iteration but got: " + result.getIterationsUsed(); + assert mock.isExhausted() + : "Expected mock to be exhausted"; + System.out.println(" testStopFinishReason: PASS"); + } + + // ----------------------------------------------------------------------- + // Test 2: LLM always returns tool_calls — loop caps at maxIterations + // ----------------------------------------------------------------------- + private static void testMaxIterations() throws Exception { + // Build a tool_calls response pointing to a non-existent tool + // (will be skipped by allow-list check — empty allow-list) + List> fakeCalls = new ArrayList<>(); + Map fakeCall = new LinkedHashMap<>(); + fakeCall.put("id", "call_1"); + Map func = new LinkedHashMap<>(); + func.put("name", "unknownTool"); + func.put("arguments", "{}"); + fakeCall.put("function", func); + fakeCall.put("type", "function"); + fakeCalls.add(fakeCall); + + // Script 5 tool_calls responses (maxIterations=4, so loop should stop at 4) + AiChatClient.ChatResponse toolCallResp = new AiChatClient.ChatResponse( + "tool_calls", null, fakeCalls, 10, 5); + MockAiChatClient mock = new MockAiChatClient( + toolCallResp, toolCallResp, toolCallResp, toolCallResp, toolCallResp); + + AgentDefinition agentDef = new AgentDefinition( + "TestAgent", "openai-default", null, 4, "You are a test.", Collections.emptyList()); + ProviderConfig provider = new ProviderConfig( + "openai-default", "https://api.openai.com/v1", "test-key", + "gpt-4o-mini", 30, Collections.emptyMap()); + + AgentRunner runner = new AgentRunner(agentDef, provider, + Collections.emptyList(), "Keep calling tools", null, null); + runner.setChatClient(mock); + + AgentRunner.RunResult result = runner.run(); + assert "max_iterations".equals(result.getStopReason()) + : "Expected max_iterations but got: " + result.getStopReason(); + assert result.getIterationsUsed() == 4 + : "Expected 4 iterations but got: " + result.getIterationsUsed(); + System.out.println(" testMaxIterations: PASS"); + } + + // ----------------------------------------------------------------------- + // Test 3: Tool result > 8000 chars is truncated + // ----------------------------------------------------------------------- + private static void testToolResultTruncation() { + // Build a string > 8000 chars + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 900; i++) { + sb.append("0123456789"); + } + String longResult = sb.toString(); // 9000 chars + + // Verify the truncation logic matches AgentRunner's constant + String truncated = longResult.length() > 8000 + ? longResult.substring(0, 8000) + "...[truncated]" + : longResult; + assert truncated.length() == 8014 + : "Truncated length should be 8014 (8000 + 14) but got: " + truncated.length(); + assert truncated.endsWith("...[truncated]") + : "Should end with '...[truncated]'"; + System.out.println(" testToolResultTruncation: PASS"); + } +} From c05ec7e74f375d8da7479d96aec315e2e409079e Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 13:11:09 +0530 Subject: [PATCH 25/70] feat(ai): Phase 3 - permission enforcement, AiProviderCost entity, getUsageSummary service --- ai/data/AiAgentSeedData.xml | 8 ++ ai/entitydef/AiAgentEntities.xml | 12 ++ ai/servicedef/services.xml | 14 +++ .../org/apache/ofbiz/ai/AiAgentServices.java | 116 ++++++++++++++++++ .../apache/ofbiz/ai/agent/AgentRunner.java | 14 +++ 5 files changed, 164 insertions(+) diff --git a/ai/data/AiAgentSeedData.xml b/ai/data/AiAgentSeedData.xml index 099a09558..558b2b733 100644 --- a/ai/data/AiAgentSeedData.xml +++ b/ai/data/AiAgentSeedData.xml @@ -44,4 +44,12 @@ under the License. + + + + + + + + diff --git a/ai/entitydef/AiAgentEntities.xml b/ai/entitydef/AiAgentEntities.xml index f176b3874..96dd332ad 100644 --- a/ai/entitydef/AiAgentEntities.xml +++ b/ai/entitydef/AiAgentEntities.xml @@ -58,4 +58,16 @@ under the License. + + + + + + + + + + + diff --git a/ai/servicedef/services.xml b/ai/servicedef/services.xml index 6a4f019de..baa82d1d0 100644 --- a/ai/servicedef/services.xml +++ b/ai/servicedef/services.xml @@ -59,4 +59,18 @@ under the License. + + Query AI agent run history and estimate token cost for a date range + + + + + + + + + + diff --git a/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java b/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java index 1e25301da..1bebfd975 100644 --- a/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java +++ b/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java @@ -18,13 +18,26 @@ *******************************************************************************/ package org.apache.ofbiz.ai; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.List; import java.util.Map; +import org.apache.ofbiz.ai.agent.AgentDefinition; import org.apache.ofbiz.ai.agent.AgentRunner; +import org.apache.ofbiz.ai.agent.ProviderConfig; +import org.apache.ofbiz.ai.container.AiContainer; import org.apache.ofbiz.base.util.Debug; import org.apache.ofbiz.base.util.GeneralException; import org.apache.ofbiz.base.util.UtilValidate; +import org.apache.ofbiz.entity.Delegator; +import org.apache.ofbiz.entity.GenericEntityException; import org.apache.ofbiz.entity.GenericValue; +import org.apache.ofbiz.entity.condition.EntityCondition; +import org.apache.ofbiz.entity.condition.EntityOperator; +import org.apache.ofbiz.entity.util.EntityQuery; import org.apache.ofbiz.service.DispatchContext; import org.apache.ofbiz.service.ServiceUtil; @@ -58,4 +71,107 @@ public static Map agentRun(DispatchContext dctx, return ServiceUtil.returnError(e.getMessage()); } } + + public static Map getUsageSummary(DispatchContext dctx, + Map context) { + Delegator delegator = dctx.getDelegator(); + String agentNameFilter = (String) context.get("agentName"); + String userLoginId = (String) context.get("userLoginId"); + Timestamp fromDate = (Timestamp) context.get("fromDate"); + Timestamp thruDate = (Timestamp) context.get("thruDate"); + + try { + // Build conditions for completed runs only + List conditions = new ArrayList<>(); + conditions.add(EntityCondition.makeCondition("statusId", + EntityOperator.EQUALS, "AI_RUN_COMPLETED")); + if (UtilValidate.isNotEmpty(agentNameFilter)) { + conditions.add(EntityCondition.makeCondition("agentName", + EntityOperator.EQUALS, agentNameFilter)); + } + if (UtilValidate.isNotEmpty(userLoginId)) { + conditions.add(EntityCondition.makeCondition("userLoginId", + EntityOperator.EQUALS, userLoginId)); + } + if (fromDate != null) { + conditions.add(EntityCondition.makeCondition("startedAt", + EntityOperator.GREATER_THAN_EQUAL_TO, fromDate)); + } + if (thruDate != null) { + conditions.add(EntityCondition.makeCondition("startedAt", + EntityOperator.LESS_THAN_EQUAL_TO, thruDate)); + } + EntityCondition cond = conditions.size() == 1 + ? conditions.get(0) + : EntityCondition.makeCondition(conditions, EntityOperator.AND); + + List runs = EntityQuery.use(delegator) + .from("AiAgentRun") + .where(cond) + .queryList(); + + long totalRuns = runs.size(); + long totalInputTokens = 0L; + long totalOutputTokens = 0L; + String lastAgentNameSeen = null; + for (GenericValue run : runs) { + if (run.getLong("inputTokens") != null) { + totalInputTokens += run.getLong("inputTokens"); + } + if (run.getLong("outputTokens") != null) { + totalOutputTokens += run.getLong("outputTokens"); + } + lastAgentNameSeen = run.getString("agentName"); + } + + // Resolve effective agent name for cost lookup + String effectiveAgentName = UtilValidate.isNotEmpty(agentNameFilter) + ? agentNameFilter : lastAgentNameSeen; + + // Look up cost by resolving model from the agent definition + BigDecimal estimatedCostUsd = null; + if (effectiveAgentName != null && AiContainer.getAgentRegistry() != null) { + AgentDefinition agentDef = AiContainer.getAgentRegistry().getAgent(effectiveAgentName); + if (agentDef != null) { + ProviderConfig provider = AiContainer.getProviderRegistry() != null + ? AiContainer.getProviderRegistry().getProvider(agentDef.getProviderName()) + : null; + String modelId = agentDef.getModelOverride() != null + ? agentDef.getModelOverride() + : (provider != null ? provider.getModel() : null); + if (modelId != null) { + GenericValue costRow = EntityQuery.use(delegator) + .from("AiProviderCost") + .where("modelId", modelId) + .orderBy("-effectiveDate") + .queryFirst(); + if (costRow != null) { + BigDecimal inputCost = costRow.getBigDecimal("inputCostPerMillion"); + BigDecimal outputCost = costRow.getBigDecimal("outputCostPerMillion"); + if (inputCost != null && outputCost != null) { + BigDecimal million = new BigDecimal("1000000"); + estimatedCostUsd = inputCost + .multiply(BigDecimal.valueOf(totalInputTokens)) + .divide(million, 6, RoundingMode.HALF_UP) + .add(outputCost + .multiply(BigDecimal.valueOf(totalOutputTokens)) + .divide(million, 6, RoundingMode.HALF_UP)); + } + } + } + } + } + + Map result = ServiceUtil.returnSuccess(); + result.put("totalRuns", totalRuns); + result.put("totalInputTokens", totalInputTokens); + result.put("totalOutputTokens", totalOutputTokens); + result.put("estimatedCostUsd", estimatedCostUsd); + return result; + + } catch (GenericEntityException e) { + Debug.logError(e, "getUsageSummary failed", MODULE); + return ServiceUtil.returnError(e.getMessage()); + } + } } diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java index 8edae78fd..1caa1a466 100644 --- a/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java +++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java @@ -37,6 +37,7 @@ import org.apache.ofbiz.entity.Delegator; import org.apache.ofbiz.entity.GenericEntityException; import org.apache.ofbiz.entity.GenericValue; +import org.apache.ofbiz.security.Security; import org.apache.ofbiz.service.DispatchContext; import org.apache.ofbiz.service.ServiceUtil; @@ -333,6 +334,19 @@ private String invokeToolService(ToolDescriptor descriptor, String toolArgsJson, parsedArgs = new HashMap<>(); } + // Permission check — enforce before dispatching the service + String requiredPermission = descriptor.getRequiredPermission(); + if (requiredPermission != null && userLogin != null && dctx != null) { + Security security = dctx.getSecurity(); + if (!security.hasPermission(requiredPermission, userLogin)) { + String permDenied = "{\"error\": \"Permission denied: requires " + + requiredPermission + "\"}"; + persistToolCall(delegator, runId, descriptor.getName(), toolArgsJson, + permDenied, true); + return permDenied; + } + } + // Build service context — omit hidden params, always include userLogin Map ctx = new HashMap<>(); ctx.put("userLogin", userLogin); From 24a6d37e5c6417ecfcda5ed0b61fe426fdc3eb63 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 13:14:21 +0530 Subject: [PATCH 26/70] fix(ai): Phase 3 fixes - modelId type id, add warning log for missing cost --- ai/entitydef/AiAgentEntities.xml | 2 +- ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/ai/entitydef/AiAgentEntities.xml b/ai/entitydef/AiAgentEntities.xml index 96dd332ad..7044aed5f 100644 --- a/ai/entitydef/AiAgentEntities.xml +++ b/ai/entitydef/AiAgentEntities.xml @@ -61,7 +61,7 @@ under the License. - + diff --git a/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java b/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java index 1bebfd975..6dc5855f9 100644 --- a/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java +++ b/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java @@ -162,6 +162,12 @@ public static Map getUsageSummary(DispatchContext dctx, } } + if (estimatedCostUsd == null && totalRuns > 0) { + Debug.logWarning("getUsageSummary: " + totalRuns + " run(s) found but cost could not" + + " be estimated — agent/provider/model not resolved or not in AiProviderCost", + MODULE); + } + Map result = ServiceUtil.returnSuccess(); result.put("totalRuns", totalRuns); result.put("totalInputTokens", totalInputTokens); From 51141f294136a686c114c46091857a25e9112c6a Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 13:18:35 +0530 Subject: [PATCH 27/70] feat(ai): Phase 4 - conversation memory: threads, messages, archiving, history --- ai/data/AiAgentSeedData.xml | 9 + ai/entitydef/AiAgentEntities.xml | 30 ++++ ai/servicedef/services.xml | 16 ++ .../org/apache/ofbiz/ai/AiAgentServices.java | 55 ++++++ .../apache/ofbiz/ai/agent/AgentRunner.java | 168 ++++++++++++++++++ 5 files changed, 278 insertions(+) diff --git a/ai/data/AiAgentSeedData.xml b/ai/data/AiAgentSeedData.xml index 558b2b733..b7bf9918b 100644 --- a/ai/data/AiAgentSeedData.xml +++ b/ai/data/AiAgentSeedData.xml @@ -44,6 +44,15 @@ under the License. + + + + + diff --git a/ai/entitydef/AiAgentEntities.xml b/ai/entitydef/AiAgentEntities.xml index 7044aed5f..3559b8615 100644 --- a/ai/entitydef/AiAgentEntities.xml +++ b/ai/entitydef/AiAgentEntities.xml @@ -70,4 +70,34 @@ under the License. + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ai/servicedef/services.xml b/ai/servicedef/services.xml index baa82d1d0..7bf469424 100644 --- a/ai/servicedef/services.xml +++ b/ai/servicedef/services.xml @@ -54,11 +54,27 @@ under the License. Run a named AI agent with a user message and return the assistant response + + + Archive a conversation thread so future agentRun calls with the same threadId start fresh + + + + + Return message history for a conversation thread + + + + diff --git a/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java b/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java index 6dc5855f9..edc65afde 100644 --- a/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java +++ b/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java @@ -22,6 +22,7 @@ import java.math.RoundingMode; import java.sql.Timestamp; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -51,6 +52,8 @@ public static Map agentRun(DispatchContext dctx, String userMessage = (String) context.get("userMessage"); GenericValue userLogin = (GenericValue) context.get("userLogin"); + String threadId = (String) context.get("threadId"); + if (UtilValidate.isEmpty(agentName)) { return ServiceUtil.returnError("agentName is required"); } @@ -60,11 +63,15 @@ public static Map agentRun(DispatchContext dctx, try { AgentRunner runner = new AgentRunner(agentName, userMessage, userLogin, dctx); + if (threadId != null) { + runner.setThreadId(threadId); + } AgentRunner.RunResult result = runner.run(); Map serviceResult = ServiceUtil.returnSuccess(); serviceResult.put("assistantMessage", result.getAssistantMessage()); serviceResult.put("stopReason", result.getStopReason()); serviceResult.put("iterationsUsed", result.getIterationsUsed()); + serviceResult.put("threadId", threadId); return serviceResult; } catch (GeneralException e) { Debug.logError(e, "agentRun failed: " + e.getMessage(), MODULE); @@ -72,6 +79,54 @@ public static Map agentRun(DispatchContext dctx, } } + public static Map archiveConversationThread(DispatchContext dctx, + Map context) { + Delegator delegator = dctx.getDelegator(); + String threadId = (String) context.get("threadId"); + try { + GenericValue thread = EntityQuery.use(delegator) + .from("AiConversationThread") + .where("threadId", threadId) + .queryOne(); + if (thread == null) { + return ServiceUtil.returnError("Thread not found: " + threadId); + } + thread.set("statusId", "AI_THREAD_ARCHIVED"); + thread.store(); + return ServiceUtil.returnSuccess(); + } catch (GenericEntityException e) { + Debug.logError(e, "archiveConversationThread failed", MODULE); + return ServiceUtil.returnError(e.getMessage()); + } + } + + public static Map getConversationHistory(DispatchContext dctx, + Map context) { + Delegator delegator = dctx.getDelegator(); + String threadId = (String) context.get("threadId"); + try { + List rows = EntityQuery.use(delegator) + .from("AiConversationMessage") + .where("threadId", threadId) + .orderBy("sequenceNum") + .queryList(); + List> messages = new ArrayList<>(); + for (GenericValue row : rows) { + Map msg = new LinkedHashMap<>(); + msg.put("role", row.getString("role")); + msg.put("content", row.getString("content")); + msg.put("sequenceNum", row.getLong("sequenceNum")); + messages.add(msg); + } + Map result = ServiceUtil.returnSuccess(); + result.put("messages", messages); + return result; + } catch (GenericEntityException e) { + Debug.logError(e, "getConversationHistory failed", MODULE); + return ServiceUtil.returnError(e.getMessage()); + } + } + public static Map getUsageSummary(DispatchContext dctx, Map context) { Delegator delegator = dctx.getDelegator(); diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java index 1caa1a466..a0d60f9a9 100644 --- a/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java +++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java @@ -37,6 +37,7 @@ import org.apache.ofbiz.entity.Delegator; import org.apache.ofbiz.entity.GenericEntityException; import org.apache.ofbiz.entity.GenericValue; +import org.apache.ofbiz.entity.util.EntityQuery; import org.apache.ofbiz.security.Security; import org.apache.ofbiz.service.DispatchContext; import org.apache.ofbiz.service.ServiceUtil; @@ -70,6 +71,9 @@ public final class AgentRunner { // Non-final to allow Phase 2 test seam injection private AiChatClient chatClient = new AiHttpClient(); + // Optional thread id for multi-turn conversation memory + private String threadId; + // Package-private fields used only by the test constructor (null in production) private AgentDefinition testAgentDef; private ProviderConfig testProvider; @@ -125,6 +129,17 @@ void setChatClient(AiChatClient client) { this.chatClient = client; } + /** + * Sets the conversation thread id for multi-turn memory. When set, the runner + * will load prior messages from {@code AiConversationThread} / {@code AiConversationMessage} + * before the loop and persist the new exchange after the loop completes. + * + * @param threadId the thread identifier, or {@code null} to disable persistence + */ + public void setThreadId(String threadId) { + this.threadId = threadId; + } + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -190,6 +205,12 @@ public RunResult run() throws GeneralException { userMsg.put("content", userMessage); messages.add(userMsg); + // 5b. Load prior conversation history when a threadId is supplied + Delegator delegatorForThread = dctx != null ? dctx.getDelegator() : null; + if (threadId != null && delegatorForThread != null) { + loadThreadHistory(messages, threadId, delegatorForThread, agent.getSystemPrompt()); + } + // 6. Persistence — create run record (skip when delegator is unavailable, e.g. unit tests) Delegator delegator = dctx != null ? dctx.getDelegator() : null; String runId = null; @@ -304,6 +325,12 @@ public RunResult run() throws GeneralException { } } + // 10. Conversation memory — persist user + assistant messages when threadId is set + if (threadId != null && delegatorForThread != null) { + saveThreadMessages(threadId, agentName, userMessage, + loopResult.getAssistantMessage(), delegatorForThread); + } + return loopResult; } @@ -413,6 +440,147 @@ private static Map buildToolMap(List des return map; } + /** + * Loads prior conversation messages for the given thread into {@code messages}. + * Messages are inserted between the system prompt (index 0) and the current user + * message (last entry), oldest first. If the thread does not exist, is archived, + * or the history would exceed the token budget, oldest pairs are trimmed until it fits. + * + * @param messages the message list being built (must contain [system, user] already) + * @param threadId the conversation thread identifier + * @param delegator entity delegator for database access + * @param systemPrompt the agent's system prompt text (used for token budget estimation) + */ + private static void loadThreadHistory(List> messages, + String threadId, Delegator delegator, String systemPrompt) { + try { + // Check thread exists and is not archived + GenericValue thread = EntityQuery.use(delegator) + .from("AiConversationThread") + .where("threadId", threadId) + .queryOne(); + if (thread == null || "AI_THREAD_ARCHIVED".equals(thread.getString("statusId"))) { + return; // No history to load + } + + // Load messages ordered by sequenceNum + List history = EntityQuery.use(delegator) + .from("AiConversationMessage") + .where("threadId", threadId) + .orderBy("sequenceNum") + .queryList(); + + // Estimate token budget — rough heuristic: 1 token ≈ 4 chars + // Budget: 80,000 tokens (reserve space for system prompt + user message + LLM response) + int tokenBudget = 80000; + int systemPromptTokens = systemPrompt != null ? systemPrompt.length() / 4 : 0; + int remaining = tokenBudget - systemPromptTokens; + + // Build history message list + List> historyMsgs = new ArrayList<>(); + for (GenericValue msg : history) { + String role = msg.getString("role"); + String content = msg.getString("content"); + if (content == null) { + content = ""; + } + Map m = new LinkedHashMap<>(); + m.put("role", role); + m.put("content", content); + historyMsgs.add(m); + } + + // Trim oldest message pairs until within budget + int totalChars = historyMsgs.stream() + .mapToInt(m -> ((String) m.get("content")).length()).sum(); + while (totalChars > remaining * 4 && historyMsgs.size() >= 2) { + // Drop first user + assistant pair (2 messages) + int pair0Chars = ((String) historyMsgs.get(0).get("content")).length(); + int pair1Chars = ((String) historyMsgs.get(1).get("content")).length(); + historyMsgs.remove(0); + historyMsgs.remove(0); + totalChars -= (pair0Chars + pair1Chars); + } + + // Insert history after system prompt (index 1), before current user message (last) + // messages currently: [system, user] + // After insert: [system, , user] + messages.addAll(1, historyMsgs); + } catch (GenericEntityException e) { + Debug.logWarning("AgentRunner: failed to load thread history for '" + + threadId + "': " + e.getMessage(), MODULE); + } + } + + /** + * Persists the user message and assistant response as {@code AiConversationMessage} rows. + * If the thread record does not exist it is created; otherwise {@code lastActiveAt} is updated. + * + * @param threadId the conversation thread identifier + * @param agentName agent name stored on a new thread record + * @param userMessage the user's input text + * @param assistantMessage the LLM's response text (may be {@code null}) + * @param delegator entity delegator for database access + */ + private static void saveThreadMessages(String threadId, String agentName, + String userMessage, String assistantMessage, Delegator delegator) { + try { + java.sql.Timestamp now = UtilDateTime.nowTimestamp(); + + // Upsert the thread record + GenericValue thread = EntityQuery.use(delegator) + .from("AiConversationThread") + .where("threadId", threadId) + .queryOne(); + if (thread == null) { + thread = delegator.makeValue("AiConversationThread"); + thread.set("threadId", threadId); + thread.set("agentName", agentName); + thread.set("createdAt", now); + thread.set("statusId", "AI_THREAD_ACTIVE"); + delegator.create(thread); + } else { + thread.set("lastActiveAt", now); + thread.store(); + } + + // Get the current max sequence number + List existing = EntityQuery.use(delegator) + .from("AiConversationMessage") + .where("threadId", threadId) + .orderBy("-sequenceNum") + .queryList(); + long nextSeq = existing.isEmpty() ? 1L + : (existing.get(0).getLong("sequenceNum") != null + ? existing.get(0).getLong("sequenceNum") + 2L : 1L); + + // Save user message + GenericValue userMsg = delegator.makeValue("AiConversationMessage"); + userMsg.set("messageId", delegator.getNextSeqId("AiConversationMessage")); + userMsg.set("threadId", threadId); + userMsg.set("role", "user"); + userMsg.set("content", userMessage); + userMsg.set("sequenceNum", nextSeq); + userMsg.set("createdAt", now); + delegator.create(userMsg); + + // Save assistant message if present + if (assistantMessage != null) { + GenericValue assistMsg = delegator.makeValue("AiConversationMessage"); + assistMsg.set("messageId", delegator.getNextSeqId("AiConversationMessage")); + assistMsg.set("threadId", threadId); + assistMsg.set("role", "assistant"); + assistMsg.set("content", assistantMessage); + assistMsg.set("sequenceNum", nextSeq + 1L); + assistMsg.set("createdAt", now); + delegator.create(assistMsg); + } + } catch (GenericEntityException e) { + Debug.logWarning("AgentRunner: failed to save thread messages for '" + + threadId + "': " + e.getMessage(), MODULE); + } + } + /** * Persists one {@code AiAgentToolCall} row. Errors are logged but never re-thrown * so that a persistence failure cannot abort a completed LLM interaction. From ee241789e4b4be775a10e4ae1b82fbbecb4f264f Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 13:25:07 +0530 Subject: [PATCH 28/70] fix(ai): populate userLoginId on AiConversationThread creation The saveThreadMessages helper was not setting the userLoginId field when creating a new AiConversationThread record, leaving ownership information unpopulated despite the entity defining the field. --- ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java index a0d60f9a9..44913ee6e 100644 --- a/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java +++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java @@ -327,7 +327,8 @@ public RunResult run() throws GeneralException { // 10. Conversation memory — persist user + assistant messages when threadId is set if (threadId != null && delegatorForThread != null) { - saveThreadMessages(threadId, agentName, userMessage, + String userLoginId = userLogin != null ? userLogin.getString("userLoginId") : null; + saveThreadMessages(threadId, agentName, userLoginId, userMessage, loopResult.getAssistantMessage(), delegatorForThread); } @@ -523,7 +524,7 @@ private static void loadThreadHistory(List> messages, * @param delegator entity delegator for database access */ private static void saveThreadMessages(String threadId, String agentName, - String userMessage, String assistantMessage, Delegator delegator) { + String userLoginId, String userMessage, String assistantMessage, Delegator delegator) { try { java.sql.Timestamp now = UtilDateTime.nowTimestamp(); @@ -536,6 +537,7 @@ private static void saveThreadMessages(String threadId, String agentName, thread = delegator.makeValue("AiConversationThread"); thread.set("threadId", threadId); thread.set("agentName", agentName); + thread.set("userLoginId", userLoginId); thread.set("createdAt", now); thread.set("statusId", "AI_THREAD_ACTIVE"); delegator.create(thread); From 5501071b0b0af0ce454bafb3c69671f6eb1df427 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 13:28:13 +0530 Subject: [PATCH 29/70] refactor(ai): fix sequence numbering gap and use queryFirst for max sequenceNum - saveThreadMessages used +2L offset causing a gap on every call; correct offset is +1L since only one slot is consumed before user message - replaced queryList with queryFirst when fetching max sequenceNum to avoid loading the full message history on each save - added threadId empty-check guard to archiveConversationThread --- .../java/org/apache/ofbiz/ai/AiAgentServices.java | 3 +++ .../java/org/apache/ofbiz/ai/agent/AgentRunner.java | 11 +++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java b/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java index edc65afde..7a61bd9ac 100644 --- a/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java +++ b/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java @@ -83,6 +83,9 @@ public static Map archiveConversationThread(DispatchContext dctx Map context) { Delegator delegator = dctx.getDelegator(); String threadId = (String) context.get("threadId"); + if (UtilValidate.isEmpty(threadId)) { + return ServiceUtil.returnError("threadId is required"); + } try { GenericValue thread = EntityQuery.use(delegator) .from("AiConversationThread") diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java index 44913ee6e..a475062ab 100644 --- a/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java +++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java @@ -546,15 +546,14 @@ private static void saveThreadMessages(String threadId, String agentName, thread.store(); } - // Get the current max sequence number - List existing = EntityQuery.use(delegator) + // Get the current max sequence number — fetch only the most recent row + GenericValue latest = EntityQuery.use(delegator) .from("AiConversationMessage") .where("threadId", threadId) .orderBy("-sequenceNum") - .queryList(); - long nextSeq = existing.isEmpty() ? 1L - : (existing.get(0).getLong("sequenceNum") != null - ? existing.get(0).getLong("sequenceNum") + 2L : 1L); + .queryFirst(); + long nextSeq = (latest != null && latest.getLong("sequenceNum") != null) + ? latest.getLong("sequenceNum") + 1L : 1L; // Save user message GenericValue userMsg = delegator.makeValue("AiConversationMessage"); From 0d9ba9da1e8db7aad3252f5dcd548a58722ee701 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 14:17:50 +0530 Subject: [PATCH 30/70] feat(ai): Phase 5A - add AiAgentProposal and AiAgentProposalTool entities --- ai/data/AiAgentSeedData.xml | 11 ++++++++++ ai/entitydef/AiAgentEntities.xml | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/ai/data/AiAgentSeedData.xml b/ai/data/AiAgentSeedData.xml index b7bf9918b..ec458e9b9 100644 --- a/ai/data/AiAgentSeedData.xml +++ b/ai/data/AiAgentSeedData.xml @@ -61,4 +61,15 @@ under the License. + + + + + + diff --git a/ai/entitydef/AiAgentEntities.xml b/ai/entitydef/AiAgentEntities.xml index 3559b8615..004cdcc41 100644 --- a/ai/entitydef/AiAgentEntities.xml +++ b/ai/entitydef/AiAgentEntities.xml @@ -100,4 +100,39 @@ under the License. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 573f9e9359967a0a68062eff9427ac7f3a7565a1 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 14:20:26 +0530 Subject: [PATCH 31/70] feat(ai): Phase 5B - add requires-approval attribute to ToolDescriptor and ToolCatalog --- .../java/org/apache/ofbiz/ai/agent/ToolCatalog.java | 5 ++++- .../java/org/apache/ofbiz/ai/agent/ToolDescriptor.java | 10 +++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/ToolCatalog.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/ToolCatalog.java index 5d30e737e..45b142e6e 100644 --- a/ai/src/main/java/org/apache/ofbiz/ai/agent/ToolCatalog.java +++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/ToolCatalog.java @@ -148,6 +148,8 @@ private void parseTool(Element toolEl, String sourceFile, if (UtilValidate.isEmpty(requiredPermission)) { requiredPermission = null; } + boolean requiresApproval = "true".equalsIgnoreCase( + toolEl.getAttribute("requires-approval")); if (UtilValidate.isEmpty(name)) { Debug.logWarning("ToolCatalog: in '" + sourceFile @@ -196,7 +198,8 @@ private void parseTool(Element toolEl, String sourceFile, ObjectNode jsonSchema = buildJsonSchema(name, description, modelService, hiddenParams); loaded.put(name, new ToolDescriptor( - name, serviceName, description, hiddenParams, requiredPermission, jsonSchema)); + name, serviceName, description, hiddenParams, requiredPermission, + requiresApproval, jsonSchema)); Debug.logInfo("ToolCatalog: registered tool '" + name + "' -> service '" + serviceName + "'.", MODULE); } diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/ToolDescriptor.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/ToolDescriptor.java index 3a73cde75..3827c6c58 100644 --- a/ai/src/main/java/org/apache/ofbiz/ai/agent/ToolDescriptor.java +++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/ToolDescriptor.java @@ -36,16 +36,19 @@ public final class ToolDescriptor { private final String description; private final Set hiddenParams; private final String requiredPermission; + private final boolean requiresApproval; private final ObjectNode jsonSchema; public ToolDescriptor(String name, String serviceName, String description, - Set hiddenParams, String requiredPermission, ObjectNode jsonSchema) { + Set hiddenParams, String requiredPermission, boolean requiresApproval, + ObjectNode jsonSchema) { this.name = name; this.serviceName = serviceName; this.description = description; this.hiddenParams = Collections.unmodifiableSet( new LinkedHashSet<>(hiddenParams != null ? hiddenParams : Collections.emptySet())); this.requiredPermission = requiredPermission; + this.requiresApproval = requiresApproval; this.jsonSchema = jsonSchema; } @@ -70,6 +73,11 @@ public String getRequiredPermission() { return requiredPermission; } + /** Returns {@code true} if this tool requires human approval before execution. */ + public boolean isRequiresApproval() { + return requiresApproval; + } + public ObjectNode getJsonSchema() { return jsonSchema; } From dff036097b5426c66fbc5f2a47d6ebdfbbb56d2f Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 14:25:42 +0530 Subject: [PATCH 32/70] feat(ai): Phase 5C - AgentRunner suspension logic and continueFromApproval Extract the agent loop into runLoop(), add approvalRequired field and suspension detection, persist proposals via persistProposal(), update RunResult with proposalId, treat approval_required as non-failed, and add continueFromApproval() static method for resuming after human approval. --- .../apache/ofbiz/ai/agent/AgentRunner.java | 247 +++++++++++++++++- 1 file changed, 236 insertions(+), 11 deletions(-) diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java index a475062ab..ee0f953b7 100644 --- a/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java +++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java @@ -74,6 +74,9 @@ public final class AgentRunner { // Optional thread id for multi-turn conversation memory private String threadId; + // When true, any tool_calls batch triggers human approval suspension + private boolean approvalRequired = false; + // Package-private fields used only by the test constructor (null in production) private AgentDefinition testAgentDef; private ProviderConfig testProvider; @@ -140,6 +143,17 @@ public void setThreadId(String threadId) { this.threadId = threadId; } + /** + * When set to {@code true}, any tool_calls batch returned by the LLM will cause + * the agent loop to suspend and return a {@link RunResult} with stop reason + * {@code "approval_required"} rather than executing the tools immediately. + * + * @param approvalRequired {@code true} to enable human approval gating + */ + public void setApprovalRequired(boolean approvalRequired) { + this.approvalRequired = approvalRequired; + } + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -231,6 +245,117 @@ public RunResult run() throws GeneralException { } } + // 7–9. Execute the agent loop + RunResult loopResult = runLoop(messages, toolSchemas, agent, provider, + allowedTools, runId, runRecord); + + // 10. Conversation memory — persist user + assistant messages when threadId is set + if (threadId != null && delegatorForThread != null) { + String userLoginId = userLogin != null ? userLogin.getString("userLoginId") : null; + saveThreadMessages(threadId, agentName, userLoginId, userMessage, + loopResult.getAssistantMessage(), delegatorForThread); + } + + return loopResult; + } + + /** + * Resumes the agent loop after a human has approved a proposal. + * The caller is responsible for executing the pending tool calls and + * appending their results to {@code messagesWithToolResults} before + * calling this method. + * + * @param agentName name of the agent + * @param messagesWithToolResults conversation messages including tool results for the approved calls + * @param userLogin the authenticated user + * @param dctx dispatch context for subsequent tool calls + * @param existingRunId run ID of the original AiAgentRun to update + * @return the final run result + * @throws GeneralException if the agent or provider is not configured + */ + public static RunResult continueFromApproval( + String agentName, + List> messagesWithToolResults, + GenericValue userLogin, + DispatchContext dctx, + String existingRunId) throws GeneralException { + + AgentDefinition agent = AiContainer.getAgentRegistry().getAgent(agentName); + if (agent == null) { + throw new GeneralException("Unknown agent: " + agentName); + } + ProviderConfig provider = AiContainer.getProviderRegistry().getProvider(agent.getProviderName()); + if (provider == null) { + throw new GeneralException("Unconfigured provider: " + agent.getProviderName()); + } + + ToolCatalog toolCatalog = AiContainer.getToolCatalog(); + Map allowedTools = new LinkedHashMap<>(); + for (String toolName : agent.getToolAllowList()) { + ToolDescriptor d = toolCatalog.getTool(toolName); + if (d != null) { + allowedTools.put(toolName, d); + } + } + + List toolSchemas = new ArrayList<>(); + for (ToolDescriptor d : allowedTools.values()) { + toolSchemas.add(d.getJsonSchema()); + } + + // Load the existing run record so runLoop can update it + Delegator delegator = dctx != null ? dctx.getDelegator() : null; + GenericValue runRecord = null; + if (delegator != null && existingRunId != null) { + try { + runRecord = EntityQuery.use(delegator) + .from("AiAgentRun").where("runId", existingRunId).queryOne(); + } catch (GenericEntityException e) { + Debug.logWarning("AgentRunner: could not load run record for continuation: " + + e.getMessage(), MODULE); + } + } + + AgentRunner runner = new AgentRunner(agentName, "", userLogin, dctx); + return runner.runLoop(new ArrayList<>(messagesWithToolResults), + toolSchemas, agent, provider, allowedTools, existingRunId, runRecord); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Executes the agent loop (steps 7–9): iterates up to {@code maxIterations}, + * calling the LLM and dispatching tool calls, then updates the run record. + * Returns a {@link RunResult} describing how the loop terminated. + * + *

When {@link #approvalRequired} is {@code true}, or when any tool in a + * tool_calls batch has {@link ToolDescriptor#isRequiresApproval()} set, the + * loop suspends immediately — persisting a proposal record — and returns a + * result with stop reason {@code "approval_required"}. + * + * @param messages the conversation message list (mutated in place) + * @param toolSchemas JSON schemas for the tools available to this agent + * @param agent the resolved agent definition + * @param provider the resolved provider configuration + * @param allowedTools map of tool name to descriptor for this agent + * @param runId the identifier of the {@code AiAgentRun} record + * @param runRecord the {@code AiAgentRun} GenericValue to update on completion + * @return the loop result + * @throws GeneralException if a chat request fails + */ + private RunResult runLoop( + List> messages, + List toolSchemas, + AgentDefinition agent, + ProviderConfig provider, + Map allowedTools, + String runId, + GenericValue runRecord) throws GeneralException { + + Delegator delegator = dctx != null ? dctx.getDelegator() : null; + // 7. Agent loop String modelToUse = agent.getModelOverride(); int maxIterations = agent.getMaxIterations(); @@ -256,6 +381,38 @@ public RunResult run() throws GeneralException { if ("tool_calls".equals(finishReason)) { List> toolCalls = response.getToolCalls(); + // Check if human approval is required for any tool in this batch + boolean needsApproval = this.approvalRequired; + if (!needsApproval) { + for (Map tc : toolCalls) { + @SuppressWarnings("unchecked") + Map fnCheck = (Map) tc.get("function"); + if (fnCheck != null) { + ToolDescriptor tdCheck = allowedTools.get((String) fnCheck.get("name")); + if (tdCheck != null && tdCheck.isRequiresApproval()) { + needsApproval = true; + break; + } + } + } + } + + if (needsApproval) { + // Append the assistant tool_calls message before suspending + Map assistantSuspendMsg = new LinkedHashMap<>(); + assistantSuspendMsg.put("role", "assistant"); + assistantSuspendMsg.put("content", null); + assistantSuspendMsg.put("tool_calls", toolCalls); + messages.add(assistantSuspendMsg); + + String proposalId = null; + if (delegator != null && runId != null) { + proposalId = persistProposal(delegator, runId, toolCalls, messages); + } + loopResult = new RunResult(null, "approval_required", iteration + 1, proposalId); + break; + } + // Append the assistant message with tool_calls BEFORE tool results Map assistantMsg = new LinkedHashMap<>(); assistantMsg.put("role", "assistant"); @@ -316,7 +473,8 @@ public RunResult run() throws GeneralException { runRecord.set("inputTokens", totalInputTokens); runRecord.set("outputTokens", totalOutputTokens); boolean failed = "max_iterations".equals(loopResult.getStopReason()) - || (!"stop".equals(loopResult.getStopReason())); + || (!"stop".equals(loopResult.getStopReason()) + && !"approval_required".equals(loopResult.getStopReason())); runRecord.set("statusId", failed ? "AI_RUN_FAILED" : "AI_RUN_COMPLETED"); try { runRecord.store(); @@ -325,19 +483,58 @@ public RunResult run() throws GeneralException { } } - // 10. Conversation memory — persist user + assistant messages when threadId is set - if (threadId != null && delegatorForThread != null) { - String userLoginId = userLogin != null ? userLogin.getString("userLoginId") : null; - saveThreadMessages(threadId, agentName, userLoginId, userMessage, - loopResult.getAssistantMessage(), delegatorForThread); - } - return loopResult; } - // --------------------------------------------------------------------------- - // Private helpers - // --------------------------------------------------------------------------- + /** + * Persists an {@code AiAgentProposal} and associated {@code AiAgentProposalTool} rows + * for a suspended tool_calls batch awaiting human approval. + * + * @param delegator entity delegator for database access + * @param runId the parent run identifier + * @param toolCalls the tool call batch to persist + * @param messages the full conversation message list at time of suspension + * @return the generated proposal identifier, or {@code null} if persistence failed + */ + private String persistProposal(Delegator delegator, String runId, + List> toolCalls, List> messages) { + try { + String proposalId = delegator.getNextSeqId("AiAgentProposal"); + String messagesJson = MAPPER.writeValueAsString(messages); + + GenericValue proposal = delegator.makeValue("AiAgentProposal"); + proposal.set("proposalId", proposalId); + proposal.set("runId", runId); + proposal.set("agentName", agentName); + proposal.set("userLoginId", userLogin != null ? userLogin.getString("userLoginId") : null); + proposal.set("messagesJson", messagesJson); + proposal.set("statusId", "AI_PROPOSAL_PENDING"); + delegator.create(proposal); + + for (Map tc : toolCalls) { + String toolCallId = (String) tc.get("id"); + @SuppressWarnings("unchecked") + Map fn = (Map) tc.get("function"); + if (fn == null) { + continue; + } + String toolName = (String) fn.get("name"); + String callArgs = (String) fn.get("arguments"); + + GenericValue propTool = delegator.makeValue("AiAgentProposalTool"); + propTool.set("proposalToolId", delegator.getNextSeqId("AiAgentProposalTool")); + propTool.set("proposalId", proposalId); + propTool.set("toolCallId", toolCallId); + propTool.set("toolName", toolName); + propTool.set("callArguments", callArgs); + delegator.create(propTool); + } + return proposalId; + } catch (Exception e) { + Debug.logError(e, "AgentRunner: failed to persist proposal for run " + runId, MODULE); + return null; + } + } /** * Invokes the OFBiz service backing a tool and serialises the result to JSON. @@ -627,6 +824,7 @@ public static final class RunResult { private final String assistantMessage; private final String stopReason; private final int iterationsUsed; + private final String proposalId; // null when no suspension /** * Constructs a run result. @@ -638,9 +836,26 @@ public static final class RunResult { * @param iterationsUsed number of loop iterations consumed */ public RunResult(String assistantMessage, String stopReason, int iterationsUsed) { + this(assistantMessage, stopReason, iterationsUsed, null); + } + + /** + * Constructs a run result with an optional proposal identifier. + * + * @param assistantMessage the final text response from the assistant, or + * {@code null} if the loop ended without a stop + * @param stopReason one of {@code "stop"}, {@code "max_iterations"}, + * {@code "approval_required"}, or an unexpected finish reason string + * @param iterationsUsed number of loop iterations consumed + * @param proposalId the proposal identifier when stopReason is + * {@code "approval_required"}, or {@code null} otherwise + */ + public RunResult(String assistantMessage, String stopReason, + int iterationsUsed, String proposalId) { this.assistantMessage = assistantMessage; this.stopReason = stopReason; this.iterationsUsed = iterationsUsed; + this.proposalId = proposalId; } /** @@ -671,5 +886,15 @@ public String getStopReason() { public int getIterationsUsed() { return iterationsUsed; } + + /** + * Returns the proposal identifier when stopReason is {@code "approval_required"}, + * or {@code null} otherwise. + * + * @return proposal identifier, or {@code null} + */ + public String getProposalId() { + return proposalId; + } } } From 0813cbcf8f920732b7510c93d01df5312ea53201 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 14:30:11 +0530 Subject: [PATCH 33/70] =?UTF-8?q?fix(ai):=20Phase=205C=20quality=20fixes?= =?UTF-8?q?=20=E2=80=94=20AI=5FRUN=5FSUSPENDED=20status,=20null-guards,=20?= =?UTF-8?q?Javadoc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AI_RUN_SUSPENDED StatusItem so suspended runs are not incorrectly counted as completed in getUsageSummary cost queries - Use AI_RUN_SUSPENDED (not AI_RUN_COMPLETED) when loop suspends for approval - Null-guard AiContainer.getAgentRegistry/getProviderRegistry in continueFromApproval - Complete continueFromApproval Javadoc with @param dctx and re-suspension note --- ai/data/AiAgentSeedData.xml | 2 ++ .../apache/ofbiz/ai/agent/AgentRunner.java | 35 ++++++++++++++----- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/ai/data/AiAgentSeedData.xml b/ai/data/AiAgentSeedData.xml index ec458e9b9..fea0e0478 100644 --- a/ai/data/AiAgentSeedData.xml +++ b/ai/data/AiAgentSeedData.xml @@ -32,6 +32,8 @@ under the License. statusCode="COMPLETED" description="Completed" sequenceId="02"/> + If any tool encountered during the resumed loop also has + * {@code requires-approval="true"}, the loop will suspend again and return + * a new result with stop reason {@code "approval_required"}. + * + * @param agentName name of the agent declared in an {@code *.agent.xml} file * @param messagesWithToolResults conversation messages including tool results for the approved calls - * @param userLogin the authenticated user - * @param dctx dispatch context for subsequent tool calls - * @param existingRunId run ID of the original AiAgentRun to update + * @param userLogin the authenticated user for subsequent tool invocations + * @param dctx dispatch context used to run OFBiz services as tools + * @param existingRunId run ID of the original {@code AiAgentRun} record to update * @return the final run result - * @throws GeneralException if the agent or provider is not configured + * @throws GeneralException if the agent or provider is not configured, or if the + * framework container is not started */ public static RunResult continueFromApproval( String agentName, @@ -280,10 +285,16 @@ public static RunResult continueFromApproval( DispatchContext dctx, String existingRunId) throws GeneralException { + if (AiContainer.getAgentRegistry() == null) { + throw new GeneralException("AgentRegistry is not available — AiContainer may not be started"); + } AgentDefinition agent = AiContainer.getAgentRegistry().getAgent(agentName); if (agent == null) { throw new GeneralException("Unknown agent: " + agentName); } + if (AiContainer.getProviderRegistry() == null) { + throw new GeneralException("ProviderRegistry is not available — AiContainer may not be started"); + } ProviderConfig provider = AiContainer.getProviderRegistry().getProvider(agent.getProviderName()); if (provider == null) { throw new GeneralException("Unconfigured provider: " + agent.getProviderName()); @@ -472,10 +483,16 @@ private RunResult runLoop( runRecord.set("iterationsUsed", (long) loopResult.getIterationsUsed()); runRecord.set("inputTokens", totalInputTokens); runRecord.set("outputTokens", totalOutputTokens); - boolean failed = "max_iterations".equals(loopResult.getStopReason()) - || (!"stop".equals(loopResult.getStopReason()) - && !"approval_required".equals(loopResult.getStopReason())); - runRecord.set("statusId", failed ? "AI_RUN_FAILED" : "AI_RUN_COMPLETED"); + String stopReason = loopResult.getStopReason(); + String runStatus; + if ("stop".equals(stopReason)) { + runStatus = "AI_RUN_COMPLETED"; + } else if ("approval_required".equals(stopReason)) { + runStatus = "AI_RUN_SUSPENDED"; + } else { + runStatus = "AI_RUN_FAILED"; + } + runRecord.set("statusId", runStatus); try { runRecord.store(); } catch (GenericEntityException e) { From 5fece0225c32d267d4b0a335c6eb0e5c81450564 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 14:34:23 +0530 Subject: [PATCH 34/70] feat(ai): Phase 5D - approveAgentProposal and rejectAgentProposal services --- ai/servicedef/services.xml | 21 ++ .../org/apache/ofbiz/ai/AiAgentServices.java | 190 ++++++++++++++++++ 2 files changed, 211 insertions(+) diff --git a/ai/servicedef/services.xml b/ai/servicedef/services.xml index 7bf469424..bf24dbae7 100644 --- a/ai/servicedef/services.xml +++ b/ai/servicedef/services.xml @@ -58,6 +58,27 @@ under the License. + + + + + + Execute a pending agent proposal and resume the agent loop to completion + + + + + + + + Reject a pending agent proposal and obtain a graceful LLM acknowledgment + + + agentRun(DispatchContext dctx, GenericValue userLogin = (GenericValue) context.get("userLogin"); String threadId = (String) context.get("threadId"); + Boolean approvalRequired = (Boolean) context.get("approvalRequired"); if (UtilValidate.isEmpty(agentName)) { return ServiceUtil.returnError("agentName is required"); @@ -66,12 +67,16 @@ public static Map agentRun(DispatchContext dctx, if (threadId != null) { runner.setThreadId(threadId); } + if (Boolean.TRUE.equals(approvalRequired)) { + runner.setApprovalRequired(true); + } AgentRunner.RunResult result = runner.run(); Map serviceResult = ServiceUtil.returnSuccess(); serviceResult.put("assistantMessage", result.getAssistantMessage()); serviceResult.put("stopReason", result.getStopReason()); serviceResult.put("iterationsUsed", result.getIterationsUsed()); serviceResult.put("threadId", threadId); + serviceResult.put("proposalId", result.getProposalId()); return serviceResult; } catch (GeneralException e) { Debug.logError(e, "agentRun failed: " + e.getMessage(), MODULE); @@ -79,6 +84,191 @@ public static Map agentRun(DispatchContext dctx, } } + public static Map approveAgentProposal(DispatchContext dctx, + Map context) { + Delegator delegator = dctx.getDelegator(); + String proposalId = (String) context.get("proposalId"); + GenericValue userLogin = (GenericValue) context.get("userLogin"); + + if (UtilValidate.isEmpty(proposalId)) { + return ServiceUtil.returnError("proposalId is required"); + } + try { + // Load and verify proposal is pending + GenericValue proposal = EntityQuery.use(delegator) + .from("AiAgentProposal").where("proposalId", proposalId).queryOne(); + if (proposal == null) { + return ServiceUtil.returnError("Proposal not found: " + proposalId); + } + if (!"AI_PROPOSAL_PENDING".equals(proposal.getString("statusId"))) { + return ServiceUtil.returnError("Proposal is not pending: current status is " + + proposal.getString("statusId")); + } + + // Mark approved + proposal.set("statusId", "AI_PROPOSAL_APPROVED"); + proposal.set("reviewedByUserLoginId", + userLogin != null ? userLogin.getString("userLoginId") : null); + proposal.set("reviewedAt", org.apache.ofbiz.base.util.UtilDateTime.nowTimestamp()); + proposal.store(); + + // Deserialize messages + com.fasterxml.jackson.databind.ObjectMapper mapper = + new com.fasterxml.jackson.databind.ObjectMapper(); + List> messages = mapper.readValue( + proposal.getString("messagesJson"), + new com.fasterxml.jackson.core.type.TypeReference< + List>>() { }); + + // Load and execute pending tool calls, append results + List propTools = EntityQuery.use(delegator) + .from("AiAgentProposalTool") + .where("proposalId", proposalId) + .queryList(); + + for (GenericValue propTool : propTools) { + String toolCallId = propTool.getString("toolCallId"); + String toolName = propTool.getString("toolName"); + String callArgsJson = propTool.getString("callArguments"); + + Map parsedArgs; + try { + parsedArgs = mapper.readValue(callArgsJson, + new com.fasterxml.jackson.core.type.TypeReference< + Map>() { }); + } catch (Exception e) { + parsedArgs = new java.util.HashMap<>(); + } + Map ctx = new java.util.HashMap<>(parsedArgs); + ctx.put("userLogin", userLogin); + + String resultJson; + try { + Map toolResult = dctx.getDispatcher().runSync(toolName, ctx); + resultJson = mapper.writeValueAsString(toolResult); + if (resultJson.length() > 8000) { + resultJson = resultJson.substring(0, 8000) + "...[truncated]"; + } + } catch (Exception e) { + resultJson = "{\"error\": \"" + e.getMessage() + "\"}"; + } + + Map toolResultMsg = new java.util.LinkedHashMap<>(); + toolResultMsg.put("role", "tool"); + toolResultMsg.put("tool_call_id", toolCallId); + toolResultMsg.put("content", resultJson); + messages.add(toolResultMsg); + } + + // Resume the agent loop + String agentName = proposal.getString("agentName"); + String runId = proposal.getString("runId"); + AgentRunner.RunResult result = AgentRunner.continueFromApproval( + agentName, messages, userLogin, dctx, runId); + + Map serviceResult = ServiceUtil.returnSuccess(); + serviceResult.put("assistantMessage", result.getAssistantMessage()); + serviceResult.put("stopReason", result.getStopReason()); + serviceResult.put("iterationsUsed", result.getIterationsUsed()); + return serviceResult; + + } catch (GeneralException e) { + Debug.logError(e, "approveAgentProposal failed", MODULE); + return ServiceUtil.returnError(e.getMessage()); + } catch (Exception e) { + Debug.logError(e, "approveAgentProposal unexpected error", MODULE); + return ServiceUtil.returnError(e.getMessage()); + } + } + + public static Map rejectAgentProposal(DispatchContext dctx, + Map context) { + Delegator delegator = dctx.getDelegator(); + String proposalId = (String) context.get("proposalId"); + String rejectionReason = (String) context.get("rejectionReason"); + GenericValue userLogin = (GenericValue) context.get("userLogin"); + + if (UtilValidate.isEmpty(proposalId)) { + return ServiceUtil.returnError("proposalId is required"); + } + try { + // Load and verify proposal is pending + GenericValue proposal = EntityQuery.use(delegator) + .from("AiAgentProposal").where("proposalId", proposalId).queryOne(); + if (proposal == null) { + return ServiceUtil.returnError("Proposal not found: " + proposalId); + } + if (!"AI_PROPOSAL_PENDING".equals(proposal.getString("statusId"))) { + return ServiceUtil.returnError("Proposal is not pending: current status is " + + proposal.getString("statusId")); + } + + // Mark rejected + proposal.set("statusId", "AI_PROPOSAL_REJECTED"); + proposal.set("reviewedByUserLoginId", + userLogin != null ? userLogin.getString("userLoginId") : null); + proposal.set("reviewedAt", org.apache.ofbiz.base.util.UtilDateTime.nowTimestamp()); + if (UtilValidate.isNotEmpty(rejectionReason)) { + proposal.set("rejectionReason", rejectionReason); + } + proposal.store(); + + // Deserialize messages and append rejection + com.fasterxml.jackson.databind.ObjectMapper mapper = + new com.fasterxml.jackson.databind.ObjectMapper(); + List> messages = mapper.readValue( + proposal.getString("messagesJson"), + new com.fasterxml.jackson.core.type.TypeReference< + List>>() { }); + + String reason = UtilValidate.isNotEmpty(rejectionReason) + ? rejectionReason : "No reason provided."; + Map rejectionMsg = new java.util.LinkedHashMap<>(); + rejectionMsg.put("role", "user"); + rejectionMsg.put("content", + "The proposed actions have been rejected by a human reviewer. Reason: " + + reason + " Please acknowledge and provide a helpful response."); + messages.add(rejectionMsg); + + // Single LLM call for acknowledgment + if (AiContainer.getAgentRegistry() == null) { + return ServiceUtil.returnError("AgentRegistry not available"); + } + AgentDefinition agentDef = AiContainer.getAgentRegistry() + .getAgent(proposal.getString("agentName")); + if (agentDef == null) { + return ServiceUtil.returnError("Agent not found: " + proposal.getString("agentName")); + } + if (AiContainer.getProviderRegistry() == null) { + return ServiceUtil.returnError("ProviderRegistry not available"); + } + ProviderConfig provider = AiContainer.getProviderRegistry() + .getProvider(agentDef.getProviderName()); + if (provider == null) { + return ServiceUtil.returnError("Provider not configured: " + agentDef.getProviderName()); + } + + org.apache.ofbiz.ai.agent.AiChatClient client = + new org.apache.ofbiz.ai.agent.AiHttpClient(); + org.apache.ofbiz.ai.agent.AiChatClient.ChatResponse response = client.chat( + java.util.Collections.unmodifiableList(messages), + java.util.Collections.emptyList(), + agentDef.getModelOverride(), + provider); + + Map serviceResult = ServiceUtil.returnSuccess(); + serviceResult.put("assistantMessage", response.getContent()); + return serviceResult; + + } catch (GeneralException e) { + Debug.logError(e, "rejectAgentProposal failed", MODULE); + return ServiceUtil.returnError(e.getMessage()); + } catch (Exception e) { + Debug.logError(e, "rejectAgentProposal unexpected error", MODULE); + return ServiceUtil.returnError(e.getMessage()); + } + } + public static Map archiveConversationThread(DispatchContext dctx, Map context) { Delegator delegator = dctx.getDelegator(); From dcfc5c89c0892523bce79fb334c11354c2a07ce1 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 14:39:17 +0530 Subject: [PATCH 35/70] =?UTF-8?q?fix(ai):=20Phase=205D=20spec=20gaps=20?= =?UTF-8?q?=E2=80=94=20orderBy=20and=20ToolDescriptor=20resolution=20in=20?= =?UTF-8?q?approveAgentProposal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add .orderBy("proposalToolId") to AiAgentProposalTool query for deterministic execution order - Resolve ToolDescriptor from AiContainer.getToolCatalog() before dispatching each tool call - Use descriptor.getServiceName() for runSync instead of the bare tool name - Skip with error tool-role message if tool not found in catalog rather than throwing --- .../org/apache/ofbiz/ai/AiAgentServices.java | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java b/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java index 88f3f608f..7be55494b 100644 --- a/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java +++ b/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java @@ -29,6 +29,8 @@ import org.apache.ofbiz.ai.agent.AgentDefinition; import org.apache.ofbiz.ai.agent.AgentRunner; import org.apache.ofbiz.ai.agent.ProviderConfig; +import org.apache.ofbiz.ai.agent.ToolCatalog; +import org.apache.ofbiz.ai.agent.ToolDescriptor; import org.apache.ofbiz.ai.container.AiContainer; import org.apache.ofbiz.base.util.Debug; import org.apache.ofbiz.base.util.GeneralException; @@ -124,13 +126,28 @@ public static Map approveAgentProposal(DispatchContext dctx, List propTools = EntityQuery.use(delegator) .from("AiAgentProposalTool") .where("proposalId", proposalId) + .orderBy("proposalToolId") .queryList(); + ToolCatalog toolCatalog = AiContainer.getToolCatalog(); + for (GenericValue propTool : propTools) { String toolCallId = propTool.getString("toolCallId"); String toolName = propTool.getString("toolName"); String callArgsJson = propTool.getString("callArguments"); + ToolDescriptor toolDesc = toolCatalog != null ? toolCatalog.getTool(toolName) : null; + if (toolDesc == null) { + Debug.logWarning("approveAgentProposal: tool '" + toolName + + "' not found in catalog, skipping", MODULE); + Map skipMsg = new java.util.LinkedHashMap<>(); + skipMsg.put("role", "tool"); + skipMsg.put("tool_call_id", toolCallId); + skipMsg.put("content", "{\"error\": \"tool not found in catalog: " + toolName + "\"}"); + messages.add(skipMsg); + continue; + } + Map parsedArgs; try { parsedArgs = mapper.readValue(callArgsJson, @@ -144,7 +161,8 @@ public static Map approveAgentProposal(DispatchContext dctx, String resultJson; try { - Map toolResult = dctx.getDispatcher().runSync(toolName, ctx); + Map toolResult = dctx.getDispatcher() + .runSync(toolDesc.getServiceName(), ctx); resultJson = mapper.writeValueAsString(toolResult); if (resultJson.length() > 8000) { resultJson = resultJson.substring(0, 8000) + "...[truncated]"; From 8044735530da22226668e314818b7116b30a9cbc Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 14:43:20 +0530 Subject: [PATCH 36/70] fix(ai): Phase 5D quality fixes in AiAgentServices - Add static final ObjectMapper and TypeReference constants (eliminates per-call instantiation) - Move all inline fully-qualified class names to import block (ObjectMapper, TypeReference, HashMap, UtilDateTime, AiChatClient, AiHttpClient, Collections, ObjectNode) - Check ServiceUtil.isError() after every runSync and log service-level failures - Use ObjectMapper for error JSON content (prevents malformed JSON from untrusted input) - Add Debug.logError in tool dispatch catch block so silent failures appear in server logs - Extract toolRoleMessage() helper to eliminate duplicated message-building pattern --- .../org/apache/ofbiz/ai/AiAgentServices.java | 96 +++++++++++-------- 1 file changed, 57 insertions(+), 39 deletions(-) diff --git a/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java b/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java index 7be55494b..4ecc192e5 100644 --- a/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java +++ b/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java @@ -22,18 +22,23 @@ import java.math.RoundingMode; import java.sql.Timestamp; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import org.apache.ofbiz.ai.agent.AgentDefinition; import org.apache.ofbiz.ai.agent.AgentRunner; +import org.apache.ofbiz.ai.agent.AiChatClient; +import org.apache.ofbiz.ai.agent.AiHttpClient; import org.apache.ofbiz.ai.agent.ProviderConfig; import org.apache.ofbiz.ai.agent.ToolCatalog; import org.apache.ofbiz.ai.agent.ToolDescriptor; import org.apache.ofbiz.ai.container.AiContainer; import org.apache.ofbiz.base.util.Debug; import org.apache.ofbiz.base.util.GeneralException; +import org.apache.ofbiz.base.util.UtilDateTime; import org.apache.ofbiz.base.util.UtilValidate; import org.apache.ofbiz.entity.Delegator; import org.apache.ofbiz.entity.GenericEntityException; @@ -44,9 +49,18 @@ import org.apache.ofbiz.service.DispatchContext; import org.apache.ofbiz.service.ServiceUtil; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + public class AiAgentServices { private static final String MODULE = AiAgentServices.class.getName(); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final TypeReference>> MSG_LIST_TYPE = + new TypeReference>>() { }; + private static final TypeReference> MAP_TYPE = + new TypeReference>() { }; public static Map agentRun(DispatchContext dctx, Map context) { @@ -111,16 +125,12 @@ public static Map approveAgentProposal(DispatchContext dctx, proposal.set("statusId", "AI_PROPOSAL_APPROVED"); proposal.set("reviewedByUserLoginId", userLogin != null ? userLogin.getString("userLoginId") : null); - proposal.set("reviewedAt", org.apache.ofbiz.base.util.UtilDateTime.nowTimestamp()); + proposal.set("reviewedAt", UtilDateTime.nowTimestamp()); proposal.store(); // Deserialize messages - com.fasterxml.jackson.databind.ObjectMapper mapper = - new com.fasterxml.jackson.databind.ObjectMapper(); - List> messages = mapper.readValue( - proposal.getString("messagesJson"), - new com.fasterxml.jackson.core.type.TypeReference< - List>>() { }); + List> messages = OBJECT_MAPPER.readValue( + proposal.getString("messagesJson"), MSG_LIST_TYPE); // Load and execute pending tool calls, append results List propTools = EntityQuery.use(delegator) @@ -140,42 +150,47 @@ public static Map approveAgentProposal(DispatchContext dctx, if (toolDesc == null) { Debug.logWarning("approveAgentProposal: tool '" + toolName + "' not found in catalog, skipping", MODULE); - Map skipMsg = new java.util.LinkedHashMap<>(); - skipMsg.put("role", "tool"); - skipMsg.put("tool_call_id", toolCallId); - skipMsg.put("content", "{\"error\": \"tool not found in catalog: " + toolName + "\"}"); - messages.add(skipMsg); + ObjectNode errNode = OBJECT_MAPPER.createObjectNode(); + errNode.put("error", "tool not found in catalog: " + toolName); + messages.add(toolRoleMessage(toolCallId, errNode.toString())); continue; } Map parsedArgs; try { - parsedArgs = mapper.readValue(callArgsJson, - new com.fasterxml.jackson.core.type.TypeReference< - Map>() { }); + parsedArgs = OBJECT_MAPPER.readValue(callArgsJson, MAP_TYPE); } catch (Exception e) { - parsedArgs = new java.util.HashMap<>(); + parsedArgs = new HashMap<>(); } - Map ctx = new java.util.HashMap<>(parsedArgs); + Map ctx = new HashMap<>(parsedArgs); ctx.put("userLogin", userLogin); String resultJson; try { Map toolResult = dctx.getDispatcher() .runSync(toolDesc.getServiceName(), ctx); - resultJson = mapper.writeValueAsString(toolResult); - if (resultJson.length() > 8000) { - resultJson = resultJson.substring(0, 8000) + "...[truncated]"; + if (ServiceUtil.isError(toolResult)) { + String errMsg = ServiceUtil.getErrorMessage(toolResult); + Debug.logWarning("approveAgentProposal: tool '" + toolName + + "' returned service error: " + errMsg, MODULE); + ObjectNode errNode = OBJECT_MAPPER.createObjectNode(); + errNode.put("error", errMsg); + resultJson = errNode.toString(); + } else { + resultJson = OBJECT_MAPPER.writeValueAsString(toolResult); + if (resultJson.length() > 8000) { + resultJson = resultJson.substring(0, 8000) + "...[truncated]"; + } } } catch (Exception e) { - resultJson = "{\"error\": \"" + e.getMessage() + "\"}"; + Debug.logError(e, "approveAgentProposal: tool '" + toolName + + "' dispatch failed", MODULE); + ObjectNode errNode = OBJECT_MAPPER.createObjectNode(); + errNode.put("error", e.getMessage() != null ? e.getMessage() : "tool dispatch failed"); + resultJson = errNode.toString(); } - Map toolResultMsg = new java.util.LinkedHashMap<>(); - toolResultMsg.put("role", "tool"); - toolResultMsg.put("tool_call_id", toolCallId); - toolResultMsg.put("content", resultJson); - messages.add(toolResultMsg); + messages.add(toolRoleMessage(toolCallId, resultJson)); } // Resume the agent loop @@ -225,23 +240,19 @@ public static Map rejectAgentProposal(DispatchContext dctx, proposal.set("statusId", "AI_PROPOSAL_REJECTED"); proposal.set("reviewedByUserLoginId", userLogin != null ? userLogin.getString("userLoginId") : null); - proposal.set("reviewedAt", org.apache.ofbiz.base.util.UtilDateTime.nowTimestamp()); + proposal.set("reviewedAt", UtilDateTime.nowTimestamp()); if (UtilValidate.isNotEmpty(rejectionReason)) { proposal.set("rejectionReason", rejectionReason); } proposal.store(); // Deserialize messages and append rejection - com.fasterxml.jackson.databind.ObjectMapper mapper = - new com.fasterxml.jackson.databind.ObjectMapper(); - List> messages = mapper.readValue( - proposal.getString("messagesJson"), - new com.fasterxml.jackson.core.type.TypeReference< - List>>() { }); + List> messages = OBJECT_MAPPER.readValue( + proposal.getString("messagesJson"), MSG_LIST_TYPE); String reason = UtilValidate.isNotEmpty(rejectionReason) ? rejectionReason : "No reason provided."; - Map rejectionMsg = new java.util.LinkedHashMap<>(); + Map rejectionMsg = new LinkedHashMap<>(); rejectionMsg.put("role", "user"); rejectionMsg.put("content", "The proposed actions have been rejected by a human reviewer. Reason: " @@ -266,11 +277,10 @@ public static Map rejectAgentProposal(DispatchContext dctx, return ServiceUtil.returnError("Provider not configured: " + agentDef.getProviderName()); } - org.apache.ofbiz.ai.agent.AiChatClient client = - new org.apache.ofbiz.ai.agent.AiHttpClient(); - org.apache.ofbiz.ai.agent.AiChatClient.ChatResponse response = client.chat( - java.util.Collections.unmodifiableList(messages), - java.util.Collections.emptyList(), + AiChatClient client = new AiHttpClient(); + AiChatClient.ChatResponse response = client.chat( + Collections.unmodifiableList(messages), + Collections.emptyList(), agentDef.getModelOverride(), provider); @@ -446,4 +456,12 @@ public static Map getUsageSummary(DispatchContext dctx, return ServiceUtil.returnError(e.getMessage()); } } + + private static Map toolRoleMessage(String toolCallId, String content) { + Map msg = new LinkedHashMap<>(); + msg.put("role", "tool"); + msg.put("tool_call_id", toolCallId); + msg.put("content", content); + return msg; + } } From 26b49d34ded66301ec8de55c5a4474c0bf9c7fa3 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 15:12:25 +0530 Subject: [PATCH 37/70] fix(ai): widen AiProviderCost.modelId from id to id-long MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model IDs like claude-3-5-haiku-20241022 are 24 chars — exceeds the VARCHAR(20) that the id type maps to in Derby. id-long is VARCHAR(60). --- ai/entitydef/AiAgentEntities.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai/entitydef/AiAgentEntities.xml b/ai/entitydef/AiAgentEntities.xml index 004cdcc41..d18f1d5e5 100644 --- a/ai/entitydef/AiAgentEntities.xml +++ b/ai/entitydef/AiAgentEntities.xml @@ -61,7 +61,7 @@ under the License. - + From 33ad933c2a048724f5afc373c3b21688b2a410f0 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 15:44:36 +0530 Subject: [PATCH 38/70] feat(ai): Phase 6A - webapp scaffold, menu, labels, ofbiz-component webapp entry --- ai/config/AiUiLabels.xml | 94 +++++++++++++++++++++++++ ai/ofbiz-component.xml | 4 ++ ai/webapp/ai/WEB-INF/controller.xml | 33 +++++++++ ai/webapp/ai/WEB-INF/web.xml | 103 ++++++++++++++++++++++++++++ ai/webapp/ai/index.jsp | 20 ++++++ ai/widget/ai/AiMenus.xml | 39 +++++++++++ ai/widget/ai/CommonScreens.xml | 50 ++++++++++++++ 7 files changed, 343 insertions(+) create mode 100644 ai/config/AiUiLabels.xml create mode 100644 ai/webapp/ai/WEB-INF/controller.xml create mode 100644 ai/webapp/ai/WEB-INF/web.xml create mode 100644 ai/webapp/ai/index.jsp create mode 100644 ai/widget/ai/AiMenus.xml create mode 100644 ai/widget/ai/CommonScreens.xml diff --git a/ai/config/AiUiLabels.xml b/ai/config/AiUiLabels.xml new file mode 100644 index 000000000..ccfddb0f8 --- /dev/null +++ b/ai/config/AiUiLabels.xml @@ -0,0 +1,94 @@ + + + + + + + AI Admin + OFBiz AI + AI Agent Administration + You do not have permission to view this page. + + + Agent Runs + Find AI Agent Runs + Run ID + Agent Name + User + Started At + Ended At + Iterations + From Date + Thru Date + Status + + + AI Agent Run Detail + Agent Run Detail + Tool Calls + Call ID + Tool Name + Called At + Arguments + Result + Input Tokens + Output Tokens + User Message + Assistant Message + + + Usage Dashboard + AI Usage Dashboard + Filter + Usage Summary + Total Runs + Total Input Tokens + Total Output Tokens + Estimated Cost (USD) + + + Proposal Review + Find AI Agent Proposals + Proposal ID + Approve + Reject + Confirm Approve + Reject Proposal + Rejection Reason + Reviewed By + Reviewed At + + + Conversation Threads + Find AI Conversation Threads + AI Thread Detail + Thread ID + Messages + Seq # + Role + Content + Created At + Last Active At + Archive + Confirm Archive + Thread Detail + + diff --git a/ai/ofbiz-component.xml b/ai/ofbiz-component.xml index c78ece952..220e761e8 100644 --- a/ai/ofbiz-component.xml +++ b/ai/ofbiz-component.xml @@ -34,4 +34,8 @@ under the License. + + diff --git a/ai/webapp/ai/WEB-INF/controller.xml b/ai/webapp/ai/WEB-INF/controller.xml new file mode 100644 index 000000000..c4c38534f --- /dev/null +++ b/ai/webapp/ai/WEB-INF/controller.xml @@ -0,0 +1,33 @@ + + + + + + + AI Component Site Configuration File + + + + + + + + diff --git a/ai/webapp/ai/WEB-INF/web.xml b/ai/webapp/ai/WEB-INF/web.xml new file mode 100644 index 000000000..07573d353 --- /dev/null +++ b/ai/webapp/ai/WEB-INF/web.xml @@ -0,0 +1,103 @@ + + + + + + Apache OFBiz - AI Admin + AI Admin Application of the Apache OFBiz Project + + + A unique name used to identify/recognize the local dispatcher for the Service Engine + localDispatcherNameai + + + The Name of the Entity Delegator to use, defined in entityengine.xml + entityDelegatorNamedefault + + + The location of the main-decorator screen to use for this webapp; referred to as a context variable in screen def XML files. + mainDecoratorLocation + component://ai/widget/ai/CommonScreens.xml + + + The location of the menus file to be used in this webapp; referred to as a context variable in screen def XML files. + mainMenuLocation + component://ai/widget/ai/AiMenus.xml + + + Remove unnecessary whitespace from HTML output. + compressHTML + false + + + + ControlFilter + ControlFilter + org.apache.ofbiz.webapp.control.ControlFilter + + allowedPaths + /error:/control:/select:/index.html:/index.jsp:/default.html:/default.jsp:/images:/js:/ws + + + redirectPath + /control/main + + + + ContextFilter + ContextFilter + org.apache.ofbiz.webapp.control.ContextFilter + + + SameSiteFilter + SameSiteFilter + org.apache.ofbiz.webapp.control.SameSiteFilter + + + ControlFilter + /* + + + ContextFilter + /* + + + SameSiteFilter + /* + + + org.apache.ofbiz.webapp.control.ControlEventListener + org.apache.ofbiz.webapp.control.LoginEventListener + + + Main Control Servlet + ControlServlet + ControlServlet + org.apache.ofbiz.webapp.control.ControlServlet + 1 + + ControlServlet/control/* + + + index.jsp + index.html + index.htm + + diff --git a/ai/webapp/ai/index.jsp b/ai/webapp/ai/index.jsp new file mode 100644 index 000000000..0ad9a3f91 --- /dev/null +++ b/ai/webapp/ai/index.jsp @@ -0,0 +1,20 @@ +<%-- +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. +--%> + +<%response.sendRedirect("control/main");%> diff --git a/ai/widget/ai/AiMenus.xml b/ai/widget/ai/AiMenus.xml new file mode 100644 index 000000000..0b6f2618d --- /dev/null +++ b/ai/widget/ai/AiMenus.xml @@ -0,0 +1,39 @@ + + + + + +

+ + + + + + + + + + + + + + + diff --git a/ai/widget/ai/CommonScreens.xml b/ai/widget/ai/CommonScreens.xml new file mode 100644 index 000000000..59b52b453 --- /dev/null +++ b/ai/widget/ai/CommonScreens.xml @@ -0,0 +1,50 @@ + + + + + + +
+ + + + + + + + + + + + + +
+
+ + +
+ + + +
+
+ +
From 589917f12c53af0c63466b06dd4b4a3d3b5f0bc3 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 15:49:14 +0530 Subject: [PATCH 39/70] fix(ai): Phase 6A - use https schema URL in AiUiLabels.xml --- ai/config/AiUiLabels.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai/config/AiUiLabels.xml b/ai/config/AiUiLabels.xml index ccfddb0f8..cd431085f 100644 --- a/ai/config/AiUiLabels.xml +++ b/ai/config/AiUiLabels.xml @@ -19,7 +19,7 @@ under the License. --> + xsi:noNamespaceSchemaLocation="https://ofbiz.apache.org/dtds/ofbiz-properties.xsd"> AI Admin From 581b3b9ca3f74ba76ac8beaaeb212a3582c116d2 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 15:51:55 +0530 Subject: [PATCH 40/70] feat(ai): Phase 6B - Run History list screen Add FindAiAgentRun and AiAgentRunDetail routes to controller.xml. Create AiScreens.xml with FindAiAgentRun and AiAgentRunDetail screens. Create AiForms.xml with FindAiAgentRuns search form, ListAiAgentRuns results grid, ViewAiAgentRun detail form, and ListAiAgentToolCalls placeholder form for Task 6C. --- ai/webapp/ai/WEB-INF/controller.xml | 4 + ai/widget/ai/AiForms.xml | 119 ++++++++++++++++++++++++++++ ai/widget/ai/AiScreens.xml | 90 +++++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 ai/widget/ai/AiForms.xml create mode 100644 ai/widget/ai/AiScreens.xml diff --git a/ai/webapp/ai/WEB-INF/controller.xml b/ai/webapp/ai/WEB-INF/controller.xml index c4c38534f..9251f37fa 100644 --- a/ai/webapp/ai/WEB-INF/controller.xml +++ b/ai/webapp/ai/WEB-INF/controller.xml @@ -26,8 +26,12 @@ under the License. + + + + diff --git a/ai/widget/ai/AiForms.xml b/ai/widget/ai/AiForms.xml new file mode 100644 index 000000000..eb1a2903a --- /dev/null +++ b/ai/widget/ai/AiForms.xml @@ -0,0 +1,119 @@ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + +
+ +
diff --git a/ai/widget/ai/AiScreens.xml b/ai/widget/ai/AiScreens.xml new file mode 100644 index 000000000..49d52d537 --- /dev/null +++ b/ai/widget/ai/AiScreens.xml @@ -0,0 +1,90 @@ + + + + + + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + +
+
+
+
+
+
+ + +
+ + + + + + + + + +
+ + + + + + + + + + + + + + +
+
+
+
+
+
+ +
From 414b0497486a0e9546d1e1dd7f52c1bd560e85c7 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 15:54:28 +0530 Subject: [PATCH 41/70] fix(ai): Phase 6B - use default-map-name=parameters on FindAiAgentRuns form --- ai/widget/ai/AiForms.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai/widget/ai/AiForms.xml b/ai/widget/ai/AiForms.xml index eb1a2903a..b947f71c8 100644 --- a/ai/widget/ai/AiForms.xml +++ b/ai/widget/ai/AiForms.xml @@ -22,7 +22,7 @@ under the License. xmlns="http://ofbiz.apache.org/Widget-Form" xsi:schemaLocation="http://ofbiz.apache.org/Widget-Form http://ofbiz.apache.org/dtds/widget-form.xsd">
+ default-map-name="parameters"> From 80bea409e1d0dc08c678baadf6923bb857206e49 Mon Sep 17 00:00:00 2001 From: Anil K Patel Date: Mon, 25 May 2026 15:58:22 +0530 Subject: [PATCH 42/70] feat(ai): Phase 6D - Usage Dashboard screen --- ai/groovyScripts/GetUsageSummary.groovy | 43 +++++++++++++++++++++++++ ai/webapp/ai/WEB-INF/controller.xml | 2 ++ ai/widget/ai/AiForms.xml | 26 +++++++++++++++ ai/widget/ai/AiScreens.xml | 33 +++++++++++++++++++ 4 files changed, 104 insertions(+) create mode 100644 ai/groovyScripts/GetUsageSummary.groovy diff --git a/ai/groovyScripts/GetUsageSummary.groovy b/ai/groovyScripts/GetUsageSummary.groovy new file mode 100644 index 000000000..5e128de83 --- /dev/null +++ b/ai/groovyScripts/GetUsageSummary.groovy @@ -0,0 +1,43 @@ +/* + * 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. + */ + +Map serviceCtx = [:] +if (parameters.agentName) { + serviceCtx.agentName = parameters.agentName +} +if (parameters.userLoginId) { + serviceCtx.userLoginId = parameters.userLoginId +} +if (parameters.fromDate) { + serviceCtx.fromDate = parameters.fromDate +} +if (parameters.thruDate) { + serviceCtx.thruDate = parameters.thruDate +} + +Map result = dispatcher.runSync('getUsageSummary', serviceCtx) +if (org.apache.ofbiz.service.ServiceUtil.isError(result)) { + context.errorMessage = org.apache.ofbiz.service.ServiceUtil.getErrorMessage(result) + return +} + +context.totalRuns = result.totalRuns +context.totalInputTokens = result.totalInputTokens +context.totalOutputTokens = result.totalOutputTokens +context.estimatedCostUsd = result.estimatedCostUsd diff --git a/ai/webapp/ai/WEB-INF/controller.xml b/ai/webapp/ai/WEB-INF/controller.xml index 9251f37fa..2b3816b91 100644 --- a/ai/webapp/ai/WEB-INF/controller.xml +++ b/ai/webapp/ai/WEB-INF/controller.xml @@ -28,10 +28,12 @@ under the License. + + diff --git a/ai/widget/ai/AiForms.xml b/ai/widget/ai/AiForms.xml index b947f71c8..c38e96e19 100644 --- a/ai/widget/ai/AiForms.xml +++ b/ai/widget/ai/AiForms.xml @@ -116,4 +116,30 @@ under the License. + +
+ + + + + + +
+ +
+ + + + + + + + + + + + +
+ diff --git a/ai/widget/ai/AiScreens.xml b/ai/widget/ai/AiScreens.xml index 49d52d537..4e89bd14f 100644 --- a/ai/widget/ai/AiScreens.xml +++ b/ai/widget/ai/AiScreens.xml @@ -87,4 +87,37 @@ under the License. + + +
+ + + +