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 String DEFAULT_PROVIDER = "openai-default";
+
+ private AiWorker() { }
+
+ /**
+ * Sends a chat request to the default configured provider and returns the
+ * assistant's text response.
+ *
+ *
> messages) throws GeneralException {
+ ProviderConfig provider = resolveProvider();
+ if (provider == null) {
+ return "AI service is not available. Check ai.properties configuration.";
+ }
+ AiChatClient client = new AiHttpClient();
+ AiChatClient.ChatResponse response = client.chat(messages,
+ Collections.emptyList(), null, provider, null);
+ 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 {
+ 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 {
+ schemaJson = OBJECT_MAPPER.writeValueAsString(schema);
+ } catch (Exception e) {
+ Debug.logWarning("AiWorker: could not serialise schema map: " + e.getMessage(), MODULE);
+ schemaJson = schema.toString();
+ }
+
+ 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, null);
+
+ String content = response.getContent();
+ if (UtilValidate.isEmpty(content)) {
+ throw new GeneralException("AiWorker: LLM returned empty content for generateStructured.");
+ }
+
+ 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);
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // 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;
+ }
+ 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 provider;
+ }
+}
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..4eec6fe2e
--- /dev/null
+++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentDefinition.java
@@ -0,0 +1,81 @@
+/*******************************************************************************
+ * 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;
+ private final String responseSchema;
+
+ public AgentDefinition(String name, String providerName, String modelOverride,
+ int maxIterations, String systemPrompt, List toolAllowList,
+ String responseSchema) {
+ 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()));
+ this.responseSchema = responseSchema;
+ }
+
+ 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;
+ }
+
+ public String getResponseSchema() {
+ return responseSchema;
+ }
+}
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..cce27b993
--- /dev/null
+++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRegistry.java
@@ -0,0 +1,273 @@
+/*******************************************************************************
+ * 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();
+ try {
+ dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
+ dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
+ dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
+ } catch (javax.xml.parsers.ParserConfigurationException e) {
+ Debug.logWarning("AgentRegistry: could not set XML security features: " + e.getMessage(), MODULE);
+ }
+ dbf.setXIncludeAware(false);
+ dbf.setExpandEntityReferences(false);
+ 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;
+ }
+
+ Element docRoot = doc.getDocumentElement();
+ if (docRoot == null) {
+ Debug.logWarning("AgentRegistry: file '" + file.getAbsolutePath()
+ + "' has no root element, skipping.", MODULE);
+ return;
+ }
+ docRoot.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, null));
+ 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();
+ if (!promptPath.startsWith(componentRoot.normalize())) {
+ throw new IllegalStateException("AgentRegistry: system-prompt-location '"
+ + location + "' attempts to traverse outside component root");
+ }
+ 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/AgentRunner.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java
new file mode 100644
index 000000000..3944f735f
--- /dev/null
+++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/AgentRunner.java
@@ -0,0 +1,1017 @@
+/*******************************************************************************
+ * 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.base.util.UtilDateTime;
+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.util.EntityQuery;
+import org.apache.ofbiz.security.Security;
+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();
+
+ // 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;
+ private Map testTools;
+
+ /**
+ * 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 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.
+ *
+ * @param client the {@link AiChatClient} to use for this run
+ */
+ 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;
+ }
+
+ /**
+ * 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
+ // ---------------------------------------------------------------------------
+
+ /**
+ * 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 — use test seam if available, otherwise query DB
+ AgentDefinition agent;
+ if (testAgentDef != null) {
+ agent = testAgentDef;
+ } else if (dctx != null) {
+ agent = loadAgentFromDb(agentName, dctx.getDelegator());
+ } else {
+ throw new GeneralException("Unknown agent: " + agentName
+ + " (no delegator available for DB lookup)");
+ }
+
+ // 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());
+ }
+
+ // Select chat client based on provider type (skip when test seam is active)
+ if (testProvider == null) {
+ this.chatClient = createChatClientForProvider(provider);
+ }
+
+ // 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);
+ }
+ }
+
+ // 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);
+
+ // 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;
+ 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–9. Execute the agent loop
+ RunResult loopResult = runLoop(messages, toolSchemas, agent, provider,
+ allowedTools, runId, runRecord, agent.getResponseSchema());
+
+ // 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.
+ *
+ * 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 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, or if the
+ * framework container is not started
+ */
+ public static RunResult continueFromApproval(
+ String agentName,
+ List> messagesWithToolResults,
+ GenericValue userLogin,
+ DispatchContext dctx,
+ String existingRunId) throws GeneralException {
+
+ if (AiContainer.getProviderRegistry() == null) {
+ throw new GeneralException("ProviderRegistry is not available — AiContainer may not be started");
+ }
+ AgentDefinition agent = loadAgentFromDb(agentName, dctx.getDelegator());
+ 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);
+ runner.chatClient = createChatClientForProvider(provider);
+ return runner.runLoop(new ArrayList<>(messagesWithToolResults),
+ toolSchemas, agent, provider, allowedTools, existingRunId, runRecord,
+ agent.getResponseSchema());
+ }
+
+ // ---------------------------------------------------------------------------
+ // 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,
+ String responseSchema) throws GeneralException {
+
+ Delegator delegator = dctx != null ? dctx.getDelegator() : null;
+
+ // 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,
+ responseSchema);
+ lastResponse = response;
+ totalInputTokens += response.getInputTokens();
+ totalOutputTokens += response.getOutputTokens();
+
+ String finishReason = response.getFinishReason();
+
+ if ("stop".equals(finishReason)) {
+ loopResult = new RunResult(response.getContent(), "stop", iteration + 1,
+ null, response.getStructuredResult());
+ break;
+ }
+
+ 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");
+ 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");
+ 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");
+
+ 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, runId, delegator);
+
+ 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();
+ 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);
+ 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) {
+ Debug.logError(e, "AgentRunner: failed to update AiAgentRun record for runId=" + runId, MODULE);
+ }
+ }
+
+ return loopResult;
+ }
+
+ /**
+ * 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.
+ * Persists an {@code AiAgentToolCall} row when {@code delegator} is non-null.
+ *
+ * @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,
+ String runId, Delegator delegator) {
+ // 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<>();
+ }
+
+ // 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);
+ for (Map.Entry entry : parsedArgs.entrySet()) {
+ if (!descriptor.getHiddenParams().contains(entry.getKey())) {
+ ctx.put(entry.getKey(), entry.getValue());
+ }
+ }
+
+ // 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)) {
+ callFailed = true;
+ String errorMsg = ServiceUtil.getErrorMessage(serviceResult);
+ persistToolCall(delegator, runId, descriptor.getName(), toolArgsJson, errorMsg, callFailed);
+ return errorMsg;
+ }
+
+ // Serialise result map to JSON string
+ try {
+ resultJson = MAPPER.writeValueAsString(serviceResult);
+ if (resultJson.length() > TOOL_RESULT_MAX_CHARS) {
+ resultJson = resultJson.substring(0, TOOL_RESULT_MAX_CHARS) + "...[truncated]";
+ }
+ } catch (JsonProcessingException e) {
+ Debug.logWarning("AgentRunner: could not serialise result for tool '"
+ + descriptor.getName() + "': " + e.getMessage(), MODULE);
+ callFailed = true;
+ resultJson = "Error serialising result: " + e.getMessage();
+ }
+
+ persistToolCall(delegator, runId, descriptor.getName(), toolArgsJson, resultJson, callFailed);
+ return resultJson;
+ }
+
+ /**
+ * Returns the appropriate {@link AiChatClient} implementation for the given provider.
+ * Defaults to {@link AiHttpClient} (OpenAI-compatible) for any unrecognised type.
+ *
+ * @param provider the resolved provider configuration
+ * @return a new chat client instance
+ */
+ private static AiChatClient createChatClientForProvider(ProviderConfig provider) {
+ if ("anthropic".equals(provider.getProviderType())) {
+ return new AnthropicChatClient();
+ }
+ return new AiHttpClient();
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Loads an {@link AgentDefinition} from the {@code AiAgentDef} database
+ * entity and its associated {@code AiAgentToolGrant} rows.
+ *
+ * @param name agent name to look up
+ * @param delegator OFBiz delegator for DB access
+ * @return the populated {@link AgentDefinition}
+ * @throws GeneralException if the agent is not found, is disabled, or a DB error occurs
+ */
+ private static AgentDefinition loadAgentFromDb(String name, Delegator delegator)
+ throws GeneralException {
+ try {
+ GenericValue row = EntityQuery.use(delegator)
+ .from("AiAgentDef").where("agentName", name).queryOne();
+ if (row == null) {
+ throw new GeneralException("Unknown agent: " + name);
+ }
+ if ("AI_AGENT_DISABLED".equals(row.getString("statusId"))) {
+ throw new GeneralException("Agent '" + name + "' is disabled.");
+ }
+ List grants = EntityQuery.use(delegator)
+ .from("AiAgentToolGrant").where("agentName", name).queryList();
+ List toolAllowList = new ArrayList<>();
+ for (GenericValue grant : grants) {
+ toolAllowList.add(grant.getString("toolName"));
+ }
+ String modelOverride = row.getString("modelName");
+ if (UtilValidate.isEmpty(modelOverride)) {
+ modelOverride = null;
+ }
+ long maxIterLong = row.getLong("maxIterations") != null
+ ? row.getLong("maxIterations") : 6L;
+ return new AgentDefinition(
+ name,
+ row.getString("providerName"),
+ modelOverride,
+ (int) maxIterLong,
+ row.getString("systemPrompt"),
+ toolAllowList,
+ row.getString("responseSchema"));
+ } catch (GenericEntityException e) {
+ throw new GeneralException("Failed to load agent '" + name + "' from database", e);
+ }
+ }
+
+ /**
+ * 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 userLoginId, 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("userLoginId", userLoginId);
+ 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 — fetch only the most recent row
+ GenericValue latest = EntityQuery.use(delegator)
+ .from("AiConversationMessage")
+ .where("threadId", threadId)
+ .orderBy("-sequenceNum")
+ .queryFirst();
+ long nextSeq = (latest != null && latest.getLong("sequenceNum") != null)
+ ? latest.getLong("sequenceNum") + 1L : 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.
+ *
+ * @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);
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // 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;
+ private final String proposalId; // null when no suspension
+ private final Map structuredResult;
+
+ /**
+ * 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, 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, stopReason, iterationsUsed, proposalId, null);
+ }
+
+ /**
+ * Constructs a run result with an optional proposal identifier and structured 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"},
+ * {@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
+ * @param structuredResult parsed structured output when the agent ran in structured mode;
+ * {@code null} otherwise
+ */
+ public RunResult(String assistantMessage, String stopReason,
+ int iterationsUsed, String proposalId,
+ Map structuredResult) {
+ this.assistantMessage = assistantMessage;
+ this.stopReason = stopReason;
+ this.iterationsUsed = iterationsUsed;
+ this.proposalId = proposalId;
+ this.structuredResult = structuredResult != null
+ ? Collections.unmodifiableMap(new LinkedHashMap<>(structuredResult))
+ : null;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Returns the parsed structured result when the agent ran in structured mode,
+ * or {@code null} otherwise.
+ *
+ * @return structured result map, or {@code null}
+ */
+ public Map getStructuredResult() {
+ return structuredResult;
+ }
+ }
+}
diff --git a/ai/src/main/java/org/apache/ofbiz/ai/agent/AiAgentXmlSeeder.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/AiAgentXmlSeeder.java
new file mode 100644
index 000000000..13e8ebee1
--- /dev/null
+++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/AiAgentXmlSeeder.java
@@ -0,0 +1,260 @@
+/*******************************************************************************
+ * 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.List;
+
+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.entity.Delegator;
+import org.apache.ofbiz.entity.GenericEntityException;
+import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.entity.util.EntityQuery;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+import org.xml.sax.SAXException;
+
+/**
+ * Seeds {@code AiAgentDef} and {@code AiAgentToolGrant} database rows from
+ * all {@code ai/*.agent.xml} files found across installed OFBiz components.
+ *
+ * Seeding is idempotent: if a row already exists for a given
+ * {@code agentName} it is left untouched, preserving any edits made by
+ * administrators since the last boot.
+ */
+public final class AiAgentXmlSeeder {
+
+ private static final String MODULE = AiAgentXmlSeeder.class.getName();
+ private static final int DEFAULT_MAX_ITERATIONS = 6;
+
+ private final ToolCatalog toolCatalog;
+ private final ProviderRegistry providerRegistry;
+
+ public AiAgentXmlSeeder(ToolCatalog toolCatalog, ProviderRegistry providerRegistry) {
+ this.toolCatalog = toolCatalog;
+ this.providerRegistry = providerRegistry;
+ }
+
+ /**
+ * Scans all component {@code ai/} directories for {@code *.agent.xml} files
+ * and inserts DB rows for any agent that does not yet have an
+ * {@code AiAgentDef} record.
+ *
+ * @param delegator the OFBiz delegator used for DB writes
+ */
+ public void seed(Delegator delegator) {
+ DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
+ try {
+ dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
+ dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
+ dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
+ } catch (ParserConfigurationException e) {
+ Debug.logWarning("AiAgentXmlSeeder: XML security feature setup failed: "
+ + e.getMessage(), MODULE);
+ }
+ dbf.setXIncludeAware(false);
+ dbf.setExpandEntityReferences(false);
+ dbf.setNamespaceAware(false);
+
+ int seeded = 0;
+ 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) {
+ continue;
+ }
+ for (File agentFile : agentFiles) {
+ seeded += seedFile(agentFile, dbf, delegator, cc.rootLocation());
+ }
+ }
+ Debug.logInfo("AiAgentXmlSeeder: seeded " + seeded + " agent(s) into AiAgentDef.", MODULE);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Private helpers
+ // ---------------------------------------------------------------------------
+
+ private int seedFile(File file, DocumentBuilderFactory dbf,
+ Delegator delegator, Path componentRoot) {
+ Document doc;
+ try {
+ DocumentBuilder db = dbf.newDocumentBuilder();
+ doc = db.parse(file);
+ } catch (ParserConfigurationException | SAXException | IOException e) {
+ Debug.logWarning("AiAgentXmlSeeder: cannot parse '" + file.getAbsolutePath()
+ + "': " + e.getMessage(), MODULE);
+ return 0;
+ }
+ Element docRoot = doc.getDocumentElement();
+ if (docRoot == null) {
+ return 0;
+ }
+ docRoot.normalize();
+ NodeList agentNodes = doc.getElementsByTagName("agent");
+ int count = 0;
+ for (int i = 0; i < agentNodes.getLength(); i++) {
+ Element agentEl = (Element) agentNodes.item(i);
+ if (seedAgent(agentEl, file.getAbsolutePath(), delegator, componentRoot)) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ private boolean seedAgent(Element agentEl, String sourceFile,
+ Delegator delegator, Path componentRoot) {
+
+ 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) || UtilValidate.isEmpty(providerName)) {
+ Debug.logWarning("AiAgentXmlSeeder: agent in '" + sourceFile
+ + "' missing name or provider; skipping.", MODULE);
+ return false;
+ }
+
+ // Skip unknown providers — warn but don't fail startup
+ if (providerRegistry.getProvider(providerName) == null) {
+ Debug.logWarning("AiAgentXmlSeeder: agent '" + name
+ + "' references unconfigured provider '" + providerName + "'; skipping.", MODULE);
+ return false;
+ }
+
+ // Idempotent — skip if already in DB
+ try {
+ GenericValue existing = EntityQuery.use(delegator)
+ .from("AiAgentDef").where("agentName", name).queryOne();
+ if (existing != null) {
+ Debug.logInfo("AiAgentXmlSeeder: agent '" + name
+ + "' already in DB; skipping.", MODULE);
+ return false;
+ }
+ } catch (GenericEntityException e) {
+ Debug.logError(e, "AiAgentXmlSeeder: DB check failed for agent '" + name + "'", MODULE);
+ return false;
+ }
+
+ int maxIterations = DEFAULT_MAX_ITERATIONS;
+ if (UtilValidate.isNotEmpty(maxIterStr)) {
+ try {
+ maxIterations = Integer.parseInt(maxIterStr);
+ } catch (NumberFormatException e) {
+ Debug.logWarning("AiAgentXmlSeeder: invalid max-iterations for agent '"
+ + name + "'; using default.", MODULE);
+ }
+ }
+
+ String systemPrompt = resolveSystemPrompt(agentEl, name, componentRoot, sourceFile);
+ if (UtilValidate.isEmpty(modelOverride)) {
+ modelOverride = null;
+ }
+
+ // Collect tool allow-list
+ List toolNames = 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)) {
+ continue;
+ }
+ if (!toolCatalog.hasTool(toolName)) {
+ Debug.logWarning("AiAgentXmlSeeder: agent '" + name
+ + "' grants unknown tool '" + toolName + "'; skipping tool.", MODULE);
+ continue;
+ }
+ toolNames.add(toolName);
+ }
+
+ // Write AiAgentDef row
+ try {
+ GenericValue agentDef = delegator.makeValue("AiAgentDef");
+ agentDef.set("agentName", name);
+ agentDef.set("providerName", providerName);
+ agentDef.set("modelName", modelOverride);
+ agentDef.set("systemPrompt", systemPrompt);
+ agentDef.set("maxIterations", (long) maxIterations);
+ agentDef.set("statusId", "AI_AGENT_ACTIVE");
+ delegator.create(agentDef);
+
+ for (String toolName : toolNames) {
+ GenericValue grant = delegator.makeValue("AiAgentToolGrant");
+ grant.set("agentName", name);
+ grant.set("toolName", toolName);
+ delegator.create(grant);
+ }
+ Debug.logInfo("AiAgentXmlSeeder: seeded agent '" + name + "' ("
+ + toolNames.size() + " tool(s)).", MODULE);
+ return true;
+ } catch (GenericEntityException e) {
+ Debug.logError(e, "AiAgentXmlSeeder: failed to seed agent '" + name + "'", MODULE);
+ return false;
+ }
+ }
+
+ 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();
+ if (!promptPath.startsWith(componentRoot.normalize())) {
+ Debug.logWarning("AiAgentXmlSeeder: system-prompt-location path traversal "
+ + "rejected for agent '" + agentName + "'.", MODULE);
+ return "";
+ }
+ try {
+ return new String(Files.readAllBytes(promptPath),
+ java.nio.charset.StandardCharsets.UTF_8).trim();
+ } catch (IOException e) {
+ Debug.logWarning("AiAgentXmlSeeder: cannot read system-prompt-location for '"
+ + agentName + "': " + e.getMessage(), MODULE);
+ return "";
+ }
+ }
+ }
+ NodeList promptNodes = agentEl.getElementsByTagName("system-prompt");
+ if (promptNodes.getLength() > 0) {
+ String text = promptNodes.item(0).getTextContent();
+ return text != null ? text.trim() : "";
+ }
+ return "";
+ }
+}
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..530298549
--- /dev/null
+++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/AiChatClient.java
@@ -0,0 +1,130 @@
+/*******************************************************************************
+ * 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;
+
+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.)
+ * @param responseSchema JSON Schema string constraining the response structure;
+ * {@code null} means free-text response (existing behaviour)
+ * @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,
+ String responseSchema) 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;
+ private final Map structuredResult;
+
+ public ChatResponse(String finishReason, String content,
+ List> toolCalls,
+ int inputTokens, int outputTokens) {
+ this(finishReason, content, toolCalls, inputTokens, outputTokens, null);
+ }
+
+ public ChatResponse(String finishReason, String content,
+ List> toolCalls,
+ int inputTokens, int outputTokens,
+ Map structuredResult) {
+ this.finishReason = finishReason;
+ this.content = content;
+ this.toolCalls = toolCalls != null
+ ? Collections.unmodifiableList(new ArrayList<>(toolCalls))
+ : null;
+ this.inputTokens = inputTokens;
+ this.outputTokens = outputTokens;
+ this.structuredResult = structuredResult != null
+ ? Collections.unmodifiableMap(new LinkedHashMap<>(structuredResult))
+ : null;
+ }
+
+ /** 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;
+ }
+
+ /** Returns the parsed structured result when the agent ran in structured mode; {@code null} otherwise. */
+ public Map getStructuredResult() {
+ return structuredResult;
+ }
+ }
+}
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..2d403579c
--- /dev/null
+++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/AiHttpClient.java
@@ -0,0 +1,270 @@
+/*******************************************************************************
+ * 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.type.TypeReference;
+
+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,
+ String responseSchema) throws GeneralException {
+
+ String requestBody = buildRequestBody(messages, toolSchemas, model, provider, responseSchema);
+
+ 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, responseSchema);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Private helpers
+ // ---------------------------------------------------------------------------
+
+ private String buildRequestBody(List> messages,
+ List toolSchemas,
+ String model,
+ ProviderConfig provider,
+ String responseSchema) 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");
+ // ToolCatalog stores the schema with Anthropic-style "input_schema".
+ // OpenAI-compatible endpoints expect "parameters" instead.
+ ObjectNode functionNode = schema.deepCopy();
+ JsonNode inputSchema = functionNode.remove("input_schema");
+ if (inputSchema != null) {
+ functionNode.set("parameters", inputSchema);
+ }
+ toolNode.set("function", functionNode);
+ toolsArray.add(toolNode);
+ }
+ root.set("tools", toolsArray);
+ root.put("tool_choice", "auto");
+ }
+
+ if (responseSchema != null && !responseSchema.isBlank()) {
+ try {
+ ObjectNode jsonSchemaNode = (ObjectNode) MAPPER.readTree(responseSchema);
+ ObjectNode responseFormat = MAPPER.createObjectNode();
+ responseFormat.put("type", "json_schema");
+ ObjectNode jsonSchemaWrapper = MAPPER.createObjectNode();
+ jsonSchemaWrapper.put("name", "agent_response");
+ jsonSchemaWrapper.set("schema", jsonSchemaNode);
+ // strict=false keeps OpenAI lenient: it accepts any user-authored JSON
+ // Schema without requiring additionalProperties:false on every object or
+ // every property to be listed in "required". Output is still constrained
+ // to valid JSON matching the schema. This matches the Anthropic path.
+ jsonSchemaWrapper.put("strict", false);
+ responseFormat.set("json_schema", jsonSchemaWrapper);
+ root.set("response_format", responseFormat);
+ } catch (Exception e) {
+ Debug.logWarning("AiHttpClient: could not parse responseSchema for "
+ + "response_format, sending without it: " + e.getMessage(), MODULE);
+ }
+ }
+
+ 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, String responseSchema) 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);
+
+ Map structuredResult = null;
+ if (responseSchema != null && content != null && !content.isBlank()) {
+ try {
+ structuredResult = MAPPER.readValue(content,
+ new TypeReference>() { });
+ } catch (Exception e) {
+ Debug.logWarning("AiHttpClient: structured output response is not valid JSON, "
+ + "returning as text: " + e.getMessage(), MODULE);
+ }
+ }
+
+ return new ChatResponse(finishReason, content, toolCalls,
+ inputTokens, outputTokens, structuredResult);
+ }
+
+ 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/AnthropicChatClient.java b/ai/src/main/java/org/apache/ofbiz/ai/agent/AnthropicChatClient.java
new file mode 100644
index 000000000..076657747
--- /dev/null
+++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/AnthropicChatClient.java
@@ -0,0 +1,339 @@
+/*******************************************************************************
+ * 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 com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+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;
+
+/**
+ * {@link AiChatClient} implementation for Anthropic's Messages API.
+ *
+ * Translates the canonical OFBiz message format (OpenAI-shaped) to
+ * Anthropic's wire format on the way in, and normalises the Anthropic
+ * response back to the canonical {@link ChatResponse} on the way out.
+ * This allows {@link AgentRunner} to remain provider-agnostic.
+ */
+public final class AnthropicChatClient implements AiChatClient {
+
+ private static final String MODULE = AnthropicChatClient.class.getName();
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ private static final int BODY_SNIPPET_MAX_CHARS = 500;
+ private static final int DEFAULT_MAX_TOKENS = 4096;
+
+ private final HttpClient httpClient;
+
+ public AnthropicChatClient() {
+ this.httpClient = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .build();
+ }
+
+ @Override
+ public ChatResponse chat(List> messages,
+ List toolSchemas,
+ String model,
+ ProviderConfig provider,
+ String responseSchema) throws GeneralException {
+
+ String requestBody = buildRequestBody(messages, toolSchemas, model, provider, responseSchema);
+
+ HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
+ .uri(URI.create(provider.getBaseUrl() + "/messages"))
+ .timeout(Duration.ofSeconds(provider.getTimeoutSeconds()))
+ .header("Content-Type", "application/json")
+ .header("x-api-key", provider.getApiKey())
+ .POST(HttpRequest.BodyPublishers.ofString(requestBody));
+
+ for (Map.Entry header : provider.getExtraHeaders().entrySet()) {
+ requestBuilder.header(header.getKey(), header.getValue());
+ }
+
+ String responseBody;
+ int statusCode;
+ try {
+ HttpResponse response = httpClient.send(
+ requestBuilder.build(), HttpResponse.BodyHandlers.ofString());
+ statusCode = response.statusCode();
+ responseBody = response.body();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new GeneralException("AnthropicChatClient: request interrupted.", e);
+ } catch (IOException e) {
+ throw new GeneralException("AnthropicChatClient: I/O error: " + e.getMessage(), e);
+ }
+
+ if (statusCode < 200 || statusCode >= 300) {
+ String snippet = responseBody != null
+ ? responseBody.substring(0, Math.min(responseBody.length(), BODY_SNIPPET_MAX_CHARS))
+ : "(empty body)";
+ throw new GeneralException("AnthropicChatClient: provider returned HTTP "
+ + statusCode + ": " + snippet);
+ }
+
+ return parseResponse(responseBody, responseSchema);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Request building
+ // ---------------------------------------------------------------------------
+
+ private String buildRequestBody(List> messages,
+ List toolSchemas,
+ String model,
+ ProviderConfig provider,
+ String responseSchema) throws GeneralException {
+
+ ObjectNode root = MAPPER.createObjectNode();
+ root.put("model", (model != null && !model.isBlank()) ? model : provider.getModel());
+ root.put("max_tokens", DEFAULT_MAX_TOKENS);
+
+ // Extract system message — Anthropic puts it in a top-level "system" field
+ String systemPrompt = null;
+ List> nonSystemMessages = new ArrayList<>();
+ for (Map msg : messages) {
+ if ("system".equals(msg.get("role"))) {
+ Object content = msg.get("content");
+ if (content instanceof String) {
+ systemPrompt = (String) content;
+ }
+ } else {
+ nonSystemMessages.add(msg);
+ }
+ }
+ String effectiveSystemPrompt = systemPrompt;
+ if (responseSchema != null && !responseSchema.isBlank()) {
+ String jsonInstruction = "\n\nYou MUST respond with valid JSON only."
+ + " No explanation, no markdown, no code fences — only raw JSON"
+ + " matching this schema:\n" + responseSchema;
+ effectiveSystemPrompt = (effectiveSystemPrompt != null
+ ? effectiveSystemPrompt : "") + jsonInstruction;
+ }
+ if (effectiveSystemPrompt != null) {
+ root.put("system", effectiveSystemPrompt);
+ }
+
+ // Convert remaining messages to Anthropic format
+ ArrayNode msgArray = MAPPER.createArrayNode();
+ for (Map msg : nonSystemMessages) {
+ String role = (String) msg.get("role");
+ ObjectNode msgNode = convertMessage(role, msg);
+ if (msgNode != null) {
+ msgArray.add(msgNode);
+ }
+ }
+ root.set("messages", msgArray);
+
+ // Tools — ToolCatalog already stores schemas with "input_schema" key (Anthropic native)
+ if (toolSchemas != null && !toolSchemas.isEmpty()) {
+ ArrayNode toolsArray = MAPPER.createArrayNode();
+ for (ObjectNode schema : toolSchemas) {
+ // schema has: name, description, input_schema — exactly what Anthropic expects
+ toolsArray.add(schema.deepCopy());
+ }
+ root.set("tools", toolsArray);
+ }
+
+ try {
+ return MAPPER.writeValueAsString(root);
+ } catch (JsonProcessingException e) {
+ throw new GeneralException(
+ "AnthropicChatClient: failed to serialise request body: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Converts a single canonical message map to an Anthropic-format ObjectNode.
+ * Returns null if the message cannot be converted (logged as warning).
+ */
+ private ObjectNode convertMessage(String role, Map msg) {
+ ObjectNode node = MAPPER.createObjectNode();
+
+ if ("tool".equals(role)) {
+ // Canonical: {role:"tool", tool_call_id:"...", content:"..."}
+ // Anthropic: {role:"user", content:[{type:"tool_result", tool_use_id:"...", content:"..."}]}
+ node.put("role", "user");
+ ArrayNode contentArray = MAPPER.createArrayNode();
+ ObjectNode resultBlock = MAPPER.createObjectNode();
+ resultBlock.put("type", "tool_result");
+ resultBlock.put("tool_use_id", (String) msg.get("tool_call_id"));
+ Object content = msg.get("content");
+ resultBlock.put("content", content != null ? content.toString() : "");
+ contentArray.add(resultBlock);
+ node.set("content", contentArray);
+ return node;
+ }
+
+ if ("assistant".equals(role) && msg.containsKey("tool_calls")) {
+ // Canonical: {role:"assistant", content:null, tool_calls:[{id, type:"function", function:{name, arguments:"{}"}}]}
+ // Anthropic: {role:"assistant", content:[{type:"tool_use", id, name, input:{MAP}}]}
+ node.put("role", "assistant");
+ ArrayNode contentArray = MAPPER.createArrayNode();
+ @SuppressWarnings("unchecked")
+ List> toolCalls = (List>) msg.get("tool_calls");
+ if (toolCalls != null) {
+ for (Map tc : toolCalls) {
+ @SuppressWarnings("unchecked")
+ Map fn = (Map) tc.get("function");
+ if (fn == null) {
+ continue;
+ }
+ ObjectNode toolUse = MAPPER.createObjectNode();
+ toolUse.put("type", "tool_use");
+ toolUse.put("id", (String) tc.get("id"));
+ toolUse.put("name", (String) fn.get("name"));
+ // arguments is a JSON string — parse it to a Map for Anthropic's "input"
+ String argsJson = (String) fn.get("arguments");
+ try {
+ Map inputMap = MAPPER.readValue(argsJson,
+ new TypeReference>() { });
+ toolUse.set("input", MAPPER.valueToTree(inputMap));
+ } catch (Exception e) {
+ Debug.logWarning("AnthropicChatClient: could not parse tool arguments '"
+ + argsJson + "': " + e.getMessage(), MODULE);
+ toolUse.set("input", MAPPER.createObjectNode());
+ }
+ contentArray.add(toolUse);
+ }
+ }
+ node.set("content", contentArray);
+ return node;
+ }
+
+ // Regular user/assistant text message
+ node.put("role", role);
+ Object content = msg.get("content");
+ if (content instanceof String) {
+ node.put("content", (String) content);
+ } else if (content == null) {
+ node.put("content", "");
+ } else {
+ node.put("content", content.toString());
+ }
+ return node;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Response parsing
+ // ---------------------------------------------------------------------------
+
+ private ChatResponse parseResponse(String responseBody, String responseSchema) throws GeneralException {
+ JsonNode root;
+ try {
+ root = MAPPER.readTree(responseBody);
+ } catch (JsonProcessingException e) {
+ throw new GeneralException(
+ "AnthropicChatClient: failed to parse response JSON: " + e.getMessage(), e);
+ }
+
+ // Normalise stop_reason to canonical finish reason
+ String stopReason = root.path("stop_reason").asText("end_turn");
+ String finishReason;
+ if ("tool_use".equals(stopReason)) {
+ finishReason = "tool_calls";
+ } else {
+ finishReason = "stop";
+ }
+
+ // Parse content array
+ String textContent = null;
+ List> toolCalls = null;
+ JsonNode contentArray = root.path("content");
+ if (contentArray.isArray()) {
+ for (JsonNode block : contentArray) {
+ String type = block.path("type").asText();
+ if ("text".equals(type)) {
+ textContent = block.path("text").asText();
+ } else if ("tool_use".equals(type)) {
+ if (toolCalls == null) {
+ toolCalls = new ArrayList<>();
+ }
+ toolCalls.add(toolUseBlockToCanonical(block));
+ }
+ }
+ }
+
+ int inputTokens = root.path("usage").path("input_tokens").asInt(0);
+ int outputTokens = root.path("usage").path("output_tokens").asInt(0);
+
+ Map structuredResult = null;
+ if (responseSchema != null && textContent != null && !textContent.isBlank()) {
+ try {
+ // Strip markdown code fences if the LLM wrapped the JSON
+ String jsonText = textContent.trim();
+ if (jsonText.startsWith("```")) {
+ jsonText = jsonText.replaceAll("(?s)^```[a-z]*\\n?", "")
+ .replaceAll("```\\s*$", "").trim();
+ }
+ structuredResult = MAPPER.readValue(jsonText,
+ new com.fasterxml.jackson.core.type.TypeReference<
+ java.util.Map>() { });
+ } catch (Exception e) {
+ Debug.logWarning("AnthropicChatClient: structured output response is not "
+ + "valid JSON, returning as text: " + e.getMessage(), MODULE);
+ }
+ }
+
+ return new ChatResponse(finishReason, textContent, toolCalls,
+ inputTokens, outputTokens, structuredResult);
+ }
+
+ /**
+ * Converts an Anthropic {@code tool_use} content block to the canonical tool-call Map.
+ * Canonical: {@code {id, type:"function", function:{name, arguments:"{JSON_STRING}"}}}
+ */
+ private Map toolUseBlockToCanonical(JsonNode block) {
+ Map map = new LinkedHashMap<>();
+ map.put("id", block.path("id").asText());
+ map.put("type", "function");
+
+ Map function = new LinkedHashMap<>();
+ function.put("name", block.path("name").asText());
+ // Convert input Map back to JSON string to match canonical format
+ JsonNode inputNode = block.path("input");
+ String argsJson;
+ try {
+ argsJson = MAPPER.writeValueAsString(inputNode);
+ } catch (JsonProcessingException e) {
+ Debug.logWarning("AnthropicChatClient: could not serialise tool input: "
+ + e.getMessage(), MODULE);
+ argsJson = "{}";
+ }
+ function.put("arguments", argsJson);
+ map.put("function", function);
+ return map;
+ }
+}
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..4c7464a9f
--- /dev/null
+++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/MockAiChatClient.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.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,
+ String responseSchema)
+ 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/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..ebe9a7805
--- /dev/null
+++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/ProviderConfig.java
@@ -0,0 +1,79 @@
+/*******************************************************************************
+ * 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;
+ private final String providerType;
+
+ public ProviderConfig(String name, String baseUrl, String apiKey,
+ String model, int timeoutSeconds, Map extraHeaders,
+ String providerType) {
+ 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()));
+ this.providerType = (providerType != null && !providerType.isBlank()) ? providerType : "openai";
+ }
+
+ 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;
+ }
+
+ public String getProviderType() {
+ return providerType;
+ }
+}
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..07b9b5b6a
--- /dev/null
+++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/ProviderRegistry.java
@@ -0,0 +1,165 @@
+/*******************************************************************************
+ * 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();
+ String[] parts = pair.split(":", 2);
+ if (parts.length == 2) {
+ String hKey = parts[0].trim();
+ String hVal = parts[1].trim();
+ if (UtilValidate.isNotEmpty(hKey)) {
+ extraHeaders.put(hKey, hVal);
+ }
+ }
+ }
+ }
+
+ String providerType = props.getProperty(pfx + "type", "openai").trim();
+ if (providerType.isEmpty()) {
+ providerType = "openai";
+ }
+
+ loaded.put(name, new ProviderConfig(name, baseUrl, apiKey, model, timeout, extraHeaders, providerType));
+ 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..45b142e6e
--- /dev/null
+++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/ToolCatalog.java
@@ -0,0 +1,329 @@
+/*******************************************************************************
+ * 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();
+ try {
+ dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
+ dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
+ dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
+ } catch (javax.xml.parsers.ParserConfigurationException e) {
+ Debug.logWarning("ToolCatalog: could not set XML security features: " + e.getMessage(), MODULE);
+ }
+ dbf.setXIncludeAware(false);
+ dbf.setExpandEntityReferences(false);
+ 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;
+ }
+
+ Element docRoot = doc.getDocumentElement();
+ if (docRoot == null) {
+ Debug.logWarning("ToolCatalog: file '" + file.getAbsolutePath()
+ + "' has no root element, skipping.", MODULE);
+ return;
+ }
+ docRoot.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;
+ }
+ boolean requiresApproval = "true".equalsIgnoreCase(
+ toolEl.getAttribute("requires-approval"));
+
+ 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
+ Set hiddenParams = new LinkedHashSet<>();
+ NodeList paramNodes = toolEl.getElementsByTagName("parameter");
+ for (int i = 0; i < paramNodes.getLength(); i++) {
+ Element paramEl = (Element) paramNodes.item(i);
+ if ("true".equalsIgnoreCase(paramEl.getAttribute("hidden"))) {
+ String pName = paramEl.getAttribute("name").trim();
+ if (UtilValidate.isNotEmpty(pName)) {
+ hiddenParams.add(pName);
+ }
+ }
+ }
+
+ ObjectNode jsonSchema = buildJsonSchema(name, description, modelService, hiddenParams);
+
+ loaded.put(name, new ToolDescriptor(
+ name, serviceName, description, hiddenParams, requiredPermission,
+ requiresApproval, jsonSchema));
+ Debug.logInfo("ToolCatalog: registered tool '" + name
+ + "' -> service '" + serviceName + "'.", MODULE);
+ }
+
+ private ObjectNode buildJsonSchema(String toolName, String description,
+ ModelService modelService, Set hiddenParams) {
+
+ ObjectNode root = MAPPER.createObjectNode();
+ root.put("name", toolName);
+ root.put("description", UtilValidate.isEmpty(description) ? toolName : description);
+
+ ObjectNode inputSchema = MAPPER.createObjectNode();
+ inputSchema.put("type", "object");
+
+ ObjectNode properties = MAPPER.createObjectNode();
+ List requiredList = new ArrayList<>();
+
+ for (ModelParam param : modelService.getInModelParamList()) {
+ if (param.getInternal()) {
+ continue;
+ }
+ if (hiddenParams.contains(param.getName())) {
+ continue;
+ }
+
+ ObjectNode propNode = MAPPER.createObjectNode();
+ propNode.put("type", ofbizTypeToJsonType(param.getType()));
+
+ String paramDesc = param.getShortDisplayDescription();
+ if (UtilValidate.isNotEmpty(paramDesc)) {
+ propNode.put("description", paramDesc);
+ }
+
+ properties.set(param.getName(), propNode);
+
+ if (!param.isOptional()) {
+ requiredList.add(param.getName());
+ }
+ }
+
+ inputSchema.set("properties", properties);
+
+ if (!requiredList.isEmpty()) {
+ ArrayNode reqArray = MAPPER.createArrayNode();
+ for (String r : requiredList) {
+ reqArray.add(r);
+ }
+ inputSchema.set("required", reqArray);
+ }
+
+ root.set("input_schema", inputSchema);
+ return root;
+ }
+
+ /** Maps an OFBiz service parameter type to a JSON Schema type string. */
+ private String ofbizTypeToJsonType(String ofbizType) {
+ if (UtilValidate.isEmpty(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";
+ }
+ }
+
+ /** Returns the trimmed text content of the first child element with the given tag, or empty string. */
+ private String getElementText(Element parent, String tagName) {
+ NodeList nodes = parent.getElementsByTagName(tagName);
+ if (nodes.getLength() == 0) {
+ return "";
+ }
+ String text = nodes.item(0).getTextContent();
+ return text != null ? text.trim() : "";
+ }
+
+ // ---------------------------------------------------------------------------
+ // Public API
+ // ---------------------------------------------------------------------------
+
+ /**
+ * Returns the {@link ToolDescriptor} for the given name, or {@code null}
+ * if no such tool is registered.
+ *
+ * @param name the tool name
+ * @return the tool descriptor, or {@code null}
+ */
+ public ToolDescriptor getTool(String name) {
+ return tools.get(name);
+ }
+
+ /**
+ * Returns an unmodifiable view of all registered tools.
+ *
+ * @return collection of tool descriptors
+ */
+ public Collection getAllTools() {
+ return tools.values();
+ }
+
+ /**
+ * Returns {@code true} if a tool with the given name is registered.
+ *
+ * @param name the tool name
+ * @return whether the tool exists
+ */
+ public boolean hasTool(String name) {
+ return tools.containsKey(name);
+ }
+}
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..3827c6c58
--- /dev/null
+++ b/ai/src/main/java/org/apache/ofbiz/ai/agent/ToolDescriptor.java
@@ -0,0 +1,84 @@
+/*******************************************************************************
+ * 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 boolean requiresApproval;
+ private final ObjectNode jsonSchema;
+
+ public ToolDescriptor(String name, String serviceName, String description,
+ 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;
+ }
+
+ 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;
+ }
+
+ /** Returns {@code true} if this tool requires human approval before execution. */
+ public boolean isRequiresApproval() {
+ return requiresApproval;
+ }
+
+ public ObjectNode getJsonSchema() {
+ return jsonSchema;
+ }
+}
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..7797ce340
--- /dev/null
+++ b/ai/src/main/java/org/apache/ofbiz/ai/container/AiContainer.java
@@ -0,0 +1,134 @@
+/*******************************************************************************
+ * 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.util.List;
+
+import org.apache.ofbiz.ai.agent.AgentRegistry;
+import org.apache.ofbiz.ai.agent.AiAgentXmlSeeder;
+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.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:
+ *
+ * Obtains the default OFBiz {@link LocalDispatcher}.
+ * Constructs a {@link ProviderRegistry} from {@code ai.properties}.
+ * Constructs a {@link ToolCatalog} by scanning component {@code ai/} directories.
+ * Constructs an {@link AgentRegistry} by scanning component {@code ai/} directories.
+ *
+ *
+ * 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 ToolCatalog toolCatalog;
+ private static AgentRegistry agentRegistry;
+ private static ProviderRegistry providerRegistry;
+
+ private String name;
+
+ @Override
+ public void init(List ofbizCommands, String name, String configFile)
+ throws ContainerException {
+ this.name = name;
+ }
+
+ @Override
+ public boolean start() throws ContainerException {
+ Delegator delegator = DelegatorFactory.getDelegator("default");
+ if (delegator == null) {
+ Debug.logWarning("AiContainer: delegator not available, AI plugin disabled.", MODULE);
+ return true;
+ }
+ LocalDispatcher dispatcher = ServiceContainer.getLocalDispatcher("default", delegator);
+ if (dispatcher == null) {
+ Debug.logWarning("AiContainer: dispatcher not available, AI plugin disabled.", MODULE);
+ return true;
+ }
+ var dctx = dispatcher.getDispatchContext();
+ try {
+ providerRegistry = new ProviderRegistry(dctx);
+ toolCatalog = new ToolCatalog(dctx);
+ agentRegistry = new AgentRegistry(toolCatalog, providerRegistry, dctx);
+ new AiAgentXmlSeeder(toolCatalog, providerRegistry).seed(delegator);
+ } 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 {
+ toolCatalog = null;
+ agentRegistry = null;
+ providerRegistry = null;
+ }
+
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * 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;
+ }
+}
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");
+ }
+}
diff --git a/ai/webapp/ai/WEB-INF/controller.xml b/ai/webapp/ai/WEB-INF/controller.xml
new file mode 100644
index 000000000..fc8918e5d
--- /dev/null
+++ b/ai/webapp/ai/WEB-INF/controller.xml
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+ 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
+ localDispatcherName ai
+
+
+ The Name of the Entity Delegator to use, defined in entityengine.xml
+ entityDelegatorName default
+
+
+ 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/AiForms.xml b/ai/widget/ai/AiForms.xml
new file mode 100644
index 000000000..86f71bafc
--- /dev/null
+++ b/ai/widget/ai/AiForms.xml
@@ -0,0 +1,427 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ai/widget/ai/AiMenus.xml b/ai/widget/ai/AiMenus.xml
new file mode 100644
index 000000000..fdb7a707e
--- /dev/null
+++ b/ai/widget/ai/AiMenus.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ai/widget/ai/AiScreens.xml b/ai/widget/ai/AiScreens.xml
new file mode 100644
index 000000000..11fea31f3
--- /dev/null
+++ b/ai/widget/ai/AiScreens.xml
@@ -0,0 +1,426 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${uiLabelMap.AiViewPermissionError}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${uiLabelMap.AiViewPermissionError}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${uiLabelMap.AiViewPermissionError}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${uiLabelMap.AiViewPermissionError}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${uiLabelMap.AiViewPermissionError}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${uiLabelMap.AiViewPermissionError}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${uiLabelMap.AiViewPermissionError}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${uiLabelMap.AiViewPermissionError}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${uiLabelMap.AiViewPermissionError}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${uiLabelMap.AiViewPermissionError}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${uiLabelMap.AiViewPermissionError}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${uiLabelMap.AiViewPermissionError}
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+