diff --git a/ai/.gitignore b/ai/.gitignore new file mode 100644 index 000000000..0ef4ca541 --- /dev/null +++ b/ai/.gitignore @@ -0,0 +1,3 @@ +config/ai.properties +CLAUDE.md +docs/ diff --git a/ai/README.md b/ai/README.md new file mode 100644 index 000000000..7be8c2c7f --- /dev/null +++ b/ai/README.md @@ -0,0 +1,160 @@ +# AI Plugin for Apache OFBiz + +An optional plugin that brings LLM agent capabilities to OFBiz using only framework-native patterns — no external AI SDK, no framework modifications. + +Apache JIRA: https://issues.apache.org/jira/browse/OFBIZ-13408 +Documentation: https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=430407963 + +## What it does + +Exposes a single OFBiz service — `agentRun` — that runs a named AI agent. Agents and tools are declared in XML. The plugin handles the LLM loop, tool dispatch, observability, permission enforcement, conversation memory, and human approval gating. + +No third-party AI SDK. Uses `java.net.http.HttpClient` and Jackson, both already present in the OFBiz runtime. + +## Prerequisites + +- OFBiz trunk (Java 17+) +- An API key from OpenAI, Anthropic, or any OpenAI-compatible provider (Ollama, Groq, etc.) + +## Installation + +```bash +cp plugins/ai/config/ai.properties.template plugins/ai/config/ai.properties +``` + +Edit `ai.properties` — set your provider block and API key (this file is gitignored, never commit it). + +## Configuration + +Named provider blocks — add as many as needed: + +```properties +ai.provider.openai-default.model=gpt-4o-mini +ai.provider.openai-default.apiKey=sk-... + +ai.provider.anthropic-default.baseUrl=https://api.anthropic.com/v1 +ai.provider.anthropic-default.model=claude-sonnet-4-6 +ai.provider.anthropic-default.apiKey=sk-ant-... +ai.provider.anthropic-default.extraHeaders=anthropic-version:2023-06-01 + +ai.provider.ollama-default.baseUrl=http://localhost:11434 +ai.provider.ollama-default.model=llama3 +``` + +## Declaring tools and agents + +Tools are OFBiz services exposed to the LLM, declared in `ai/*.tools.xml` inside any component: + +```xml + + + Returns the current status of a production run given its ID. + + + Updates the status of a production run. Requires human approval. + + +``` + +Agents are declared in `ai/*.agent.xml`: + +```xml + + You are an MRP analyst. Use the tools available to answer manufacturing queries. + + + + + +``` + +The plugin scans all components' `ai/` directories at startup. + +## Usage + +Call `agentRun` from any Groovy script, service, or ECA: + +```groovy +Map result = dispatcher.runSync("agentRun", [ + agentName: "mrp-assistant", + userMessage: "Which production runs are at risk this week?", + userLogin: userLogin +]) +String answer = result.assistantMessage +``` + +Multi-turn conversation — pass a `threadId`: + +```groovy +Map result = dispatcher.runSync("agentRun", [ + agentName: "mrp-assistant", + userMessage: "What about next week?", + threadId: "thread-abc123", + userLogin: userLogin +]) +// result.threadId — pass this back on the next call +``` + +Human approval — pass `approvalRequired: true` or mark individual tools with `requires-approval="true"`: + +```groovy +Map result = dispatcher.runSync("agentRun", [ + agentName: "mrp-assistant", + userMessage: "Update all at-risk runs to ON_HOLD.", + approvalRequired: true, + userLogin: userLogin +]) +if (result.stopReason == "approval_required") { + String proposalId = result.proposalId + // store proposalId — a reviewer calls approveAgentProposal or rejectAgentProposal +} +``` + +## Services + +| Service | Purpose | +|---|---| +| `agentRun` | Run a named agent | +| `approveAgentProposal` | Execute pending tools and resume the agent loop | +| `rejectAgentProposal` | Reject a proposal; returns LLM acknowledgment | +| `getUsageSummary` | Token usage and estimated cost, filterable by agent/user/date | +| `getConversationHistory` | Messages for a thread in sequence order | +| `archiveConversationThread` | Mark thread archived | +| `aiGenerate` | Direct single-turn LLM call, no agent loop | +| `aiGenerateStructured` | Structured JSON output constrained by schema | + +## Architecture + +``` +AiContainer (startup) + ├── ProviderRegistry — reads ai.properties named blocks + ├── ToolCatalog — scans all components' ai/*.tools.xml + └── AgentRegistry — scans all components' ai/*.agent.xml + +agentRun + └── AgentRunner + ├── load thread history (if threadId) + ├── loop: AiHttpClient → LLM → tool dispatch → repeat + ├── persist AiAgentRun + AiAgentToolCall + └── save conversation messages (if threadId) +``` + +## Admin UI + +Mounted at `/ai` — requires `OFBTOOLS` permission. + +| Screen | URL | +|---|---| +| Run History | `/ai/control/FindAiAgentRun` | +| Run Detail | `/ai/control/AiAgentRunDetail` | +| Usage Dashboard | `/ai/control/AiUsageDashboard` | +| Proposal Review | `/ai/control/FindAiAgentProposal` | +| Thread Explorer | `/ai/control/FindAiConversationThread` | + +## Smoke test + +Start OFBiz, then: webtools → Service Engine → Run Service → `aiSmokeTest` diff --git a/ai/ai/ecommerce-promo-advisor.agent.xml b/ai/ai/ecommerce-promo-advisor.agent.xml new file mode 100644 index 000000000..6ad36cfc6 --- /dev/null +++ b/ai/ai/ecommerce-promo-advisor.agent.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + diff --git a/ai/ai/ecommerce-promo.tools.xml b/ai/ai/ecommerce-promo.tools.xml new file mode 100644 index 000000000..1e563aacb --- /dev/null +++ b/ai/ai/ecommerce-promo.tools.xml @@ -0,0 +1,54 @@ + + + + + + + Look up all active USD prices for a product: default (selling) price, list price, +average cost, promotional price, and competitive (competitor) price. Use this first to understand +a product's current pricing and margin before recommending a promotion. + + + + Return the total available-to-promise (ATP) inventory quantity for a product across +all warehouses, plus a per-facility breakdown. Use this to check whether we have enough stock +to support a promotion before recommending one. + + + + Return the number of orders, total quantity sold, and total revenue for a product +over the last N days (default 30). Use this to assess whether a product is selling well +or needs a promotional push. + + + + Create a SPECIAL_PROMO_PRICE for a product in USD, active for the specified number +of days (default 7). This is a WRITE operation that changes the live store price and requires +manager approval before it executes. Only call this when the user has confirmed they want to +proceed with the promotion. + + + diff --git a/ai/ai/sample.agent.xml b/ai/ai/sample.agent.xml new file mode 100644 index 000000000..d6294e59a --- /dev/null +++ b/ai/ai/sample.agent.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + diff --git a/ai/ai/sample.tools.xml b/ai/ai/sample.tools.xml new file mode 100644 index 000000000..25efa4485 --- /dev/null +++ b/ai/ai/sample.tools.xml @@ -0,0 +1,26 @@ + + + + + + Run the AI smoke test to verify the AI plugin is working. + + diff --git a/ai/build.gradle b/ai/build.gradle new file mode 100644 index 000000000..02f8d4511 --- /dev/null +++ b/ai/build.gradle @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +dependencies { +} diff --git a/ai/config/AiUiLabels.xml b/ai/config/AiUiLabels.xml new file mode 100644 index 000000000..7956bfedf --- /dev/null +++ b/ai/config/AiUiLabels.xml @@ -0,0 +1,119 @@ + + + + + + + AI Admin + OFBiz AI + AI Agent Administration + You do not have permission to view this page. + + + Agent Runs + Find AI Agent Runs + Run ID + Agent Name + User + Started At + Ended At + Iterations + From Date + Thru Date + Status + + + AI Agent Run Detail + Agent Run Detail + Tool Calls + Call ID + Tool Name + Called At + Arguments + Result + Input Tokens + Output Tokens + User Message + Assistant Message + + + Usage Dashboard + AI Usage Dashboard + Filter + Usage Summary + Total Runs + Total Input Tokens + Total Output Tokens + Estimated Cost (USD) + + + Proposal Review + Find AI Agent Proposals + Proposal ID + Approve + Reject + Confirm Approve + Reject Proposal + Rejection Reason + Reviewed By + Reviewed At + + + Conversation Threads + Find AI Conversation Threads + AI Thread Detail + Thread ID + Messages + Seq # + Role + Content + Created At + Last Active At + Archive + Confirm Archive + Thread Detail + + + Agents + Available AI Agents + Provider + Model + Max Iterations + Tools + Run + Run Agent + Agent Response + Agent Response + Stop Reason + This agent is awaiting approval to proceed. + Continue Thread + New Conversation + Send + + + Create Agent + Edit Agent + System Prompt + Response Schema (JSON) + Tool Grants + Add Tool + + diff --git a/ai/config/ai.properties b/ai/config/ai.properties new file mode 100644 index 000000000..bcdfb2598 --- /dev/null +++ b/ai/config/ai.properties @@ -0,0 +1,38 @@ +# AI Plugin configuration +# This file is gitignored — never commit API keys. +# +# Supported providers: openai | anthropic | ollama +# To switch provider: uncomment the desired block, comment out the others. + +# ------------------------------------------------- +# Provider: openai +# Models: gpt-4o, gpt-4o-mini, gpt-4-turbo, o1-mini +# Docs: https://platform.openai.com/docs/models +# ------------------------------------------------- +ai.provider=openai +ai.model=gpt-4o-mini +ai.apiKey=REPLACE_WITH_YOUR_API_KEY +ai.baseUrl= +ai.timeout=60 + +# ------------------------------------------------- +# Provider: anthropic +# Models: claude-opus-4-5, claude-sonnet-4-5, claude-3-5-haiku-20241022 +# Docs: https://docs.anthropic.com/en/docs/about-claude/models +# ------------------------------------------------- +#ai.provider=anthropic +#ai.model=claude-3-5-haiku-20241022 +#ai.apiKey=REPLACE_WITH_YOUR_API_KEY +#ai.baseUrl= +#ai.timeout=60 + +# ------------------------------------------------- +# Provider: ollama (local, no API key required) +# Models: llama3.2, llama3.1, mistral, gemma3 +# Docs: https://ollama.com/library +# ------------------------------------------------- +#ai.provider=ollama +#ai.model=llama3.2 +#ai.apiKey= +#ai.baseUrl=http://localhost:11434 +#ai.timeout=120 diff --git a/ai/data/AiAgentSeedData.xml b/ai/data/AiAgentSeedData.xml new file mode 100644 index 000000000..4ecdd7f9e --- /dev/null +++ b/ai/data/AiAgentSeedData.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ai/entitydef/AiAgentEntities.xml b/ai/entitydef/AiAgentEntities.xml new file mode 100644 index 000000000..172e7a0dd --- /dev/null +++ b/ai/entitydef/AiAgentEntities.xml @@ -0,0 +1,166 @@ + + + + + AI Agent Entity Model + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ai/groovyScripts/AiStructuredTest.groovy b/ai/groovyScripts/AiStructuredTest.groovy new file mode 100644 index 000000000..2c4cc6d53 --- /dev/null +++ b/ai/groovyScripts/AiStructuredTest.groovy @@ -0,0 +1,25 @@ +import org.apache.ofbiz.base.util.Debug +import org.apache.ofbiz.ai.AiWorker + +final String MODULE = 'AiStructuredTest.groovy' + +def messages = [ + [role: "user", content: "Return a greeting with a single word."] +] + +def schema = [ + word: "string" +] + +try { + Map result = AiWorker.generateStructured(dctx, messages, schema) + if (!result || !result.containsKey("word")) { + Debug.logError("AI structured smoke test failed: response missing 'word' key. Got: " + result, MODULE) + return error("AI structured smoke test failed: missing 'word' key in response") + } + Debug.logInfo("AI structured smoke test response: " + result, MODULE) + return success("AI structured smoke test passed: " + result) +} catch (Exception e) { + Debug.logError(e, "AI structured smoke test failed", MODULE) + return error("AI structured smoke test failed: " + e.getMessage()) +} diff --git a/ai/groovyScripts/AiTest.groovy b/ai/groovyScripts/AiTest.groovy new file mode 100644 index 000000000..9a8597b40 --- /dev/null +++ b/ai/groovyScripts/AiTest.groovy @@ -0,0 +1,17 @@ +import org.apache.ofbiz.base.util.Debug +import org.apache.ofbiz.ai.AiWorker + +final String MODULE = 'AiTest.groovy' + +def messages = [ + [role: "user", content: "Say hello in one word."] +] + +try { + String response = AiWorker.generate(dctx, messages) + Debug.logInfo("AI smoke test response: " + response, MODULE) + return success("AI smoke test passed: " + response) +} catch (Exception e) { + Debug.logError(e, "AI smoke test failed", MODULE) + return error("AI smoke test failed: " + e.getMessage()) +} diff --git a/ai/groovyScripts/GetAgentList.groovy b/ai/groovyScripts/GetAgentList.groovy new file mode 100644 index 000000000..f20a664c4 --- /dev/null +++ b/ai/groovyScripts/GetAgentList.groovy @@ -0,0 +1,34 @@ +/* + * 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. + */ + +List agentDefs = from("AiAgentDef").orderBy("agentName").queryList() + +context.agents = agentDefs.collect { def agentDef -> + int toolCount = (int) from("AiAgentToolGrant") + .where("agentName", agentDef.getString("agentName")) + .queryCount() + [ + name : agentDef.getString("agentName"), + providerName : agentDef.getString("providerName"), + modelDisplay : agentDef.getString("modelName") ?: '(default)', + maxIterations: agentDef.getLong("maxIterations") ?: 6L, + toolCount : toolCount, + statusId : agentDef.getString("statusId") + ] +} diff --git a/ai/groovyScripts/GetAvailableTools.groovy b/ai/groovyScripts/GetAvailableTools.groovy new file mode 100644 index 000000000..ccab5c03d --- /dev/null +++ b/ai/groovyScripts/GetAvailableTools.groovy @@ -0,0 +1,27 @@ +/* + * 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. + */ + +import org.apache.ofbiz.ai.container.AiContainer + +def catalog = AiContainer.getToolCatalog() +if (!catalog) { + context.availableToolNames = [] + return +} +context.availableToolNames = catalog.getAllTools().collect { it.getName() }.sort() diff --git a/ai/groovyScripts/GetConversationHistory.groovy b/ai/groovyScripts/GetConversationHistory.groovy new file mode 100644 index 000000000..1127d57e6 --- /dev/null +++ b/ai/groovyScripts/GetConversationHistory.groovy @@ -0,0 +1,26 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +Map serviceCtx = [userLogin: userLogin, threadId: parameters.threadId] +Map result = dispatcher.runSync('getConversationHistory', serviceCtx) +if (org.apache.ofbiz.service.ServiceUtil.isError(result)) { + context.errorMessage = org.apache.ofbiz.service.ServiceUtil.getErrorMessage(result) + return +} +context.threadMessages = result.messages diff --git a/ai/groovyScripts/GetProductInventorySummary.groovy b/ai/groovyScripts/GetProductInventorySummary.groovy new file mode 100644 index 000000000..e175c4232 --- /dev/null +++ b/ai/groovyScripts/GetProductInventorySummary.groovy @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +def getProductInventorySummary() { + String productId = parameters.productId + if (!productId) return error("productId is required") + + List facilities = from("ProductFacility") + .where("productId", productId) + .queryList() + + if (!facilities) { + return success([totalAtpQuantity: BigDecimal.ZERO, + facilityBreakdown: "No facility records found for ${productId}"]) + } + + BigDecimal total = BigDecimal.ZERO + List lines = [] + for (def pf : facilities) { + BigDecimal atp = pf.getBigDecimal("lastInventoryCount") ?: BigDecimal.ZERO + total = total.add(atp) + lines << "${pf.facilityId}: ${atp}" + } + + return success([totalAtpQuantity: total, + facilityBreakdown: lines.join(", ")]) +} diff --git a/ai/groovyScripts/GetProductPriceSummary.groovy b/ai/groovyScripts/GetProductPriceSummary.groovy new file mode 100644 index 000000000..1324f0eae --- /dev/null +++ b/ai/groovyScripts/GetProductPriceSummary.groovy @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +def getProductPriceSummary() { + String productId = parameters.productId + if (!productId) return error("productId is required") + + def product = from("Product").where("productId", productId).queryOne() + if (!product) return error("Product not found: ${productId}") + + List prices = from("ProductPrice") + .where("productId", productId, + "productPricePurposeId", "PURCHASE", + "currencyUomId", "USD", + "productStoreGroupId", "_NA_") + .filterByDate() + .queryList() + + Map byType = [:] + for (def p : prices) { + byType[p.productPriceTypeId] = p.getBigDecimal("price") + } + + Map result = success() + result.productName = product.getString("productName") ?: product.getString("internalName") ?: productId + result.defaultPrice = byType["DEFAULT_PRICE"] + result.listPrice = byType["LIST_PRICE"] + result.averageCost = byType["AVERAGE_COST"] + result.competitivePrice = byType["COMPETITIVE_PRICE"] + result.activePromoPrice = byType["SPECIAL_PROMO_PRICE"] + result.currencyUomId = "USD" + return result +} diff --git a/ai/groovyScripts/GetRecentOrderActivity.groovy b/ai/groovyScripts/GetRecentOrderActivity.groovy new file mode 100644 index 000000000..4ab39b7b8 --- /dev/null +++ b/ai/groovyScripts/GetRecentOrderActivity.groovy @@ -0,0 +1,70 @@ +/* + * 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. + */ + +import org.apache.ofbiz.entity.condition.EntityCondition +import org.apache.ofbiz.entity.condition.EntityOperator +import java.sql.Timestamp + +def getRecentOrderActivity() { + String productId = parameters.productId + if (!productId) return error("productId is required") + + int days = parameters.days ? parameters.days as int : 30 + Timestamp fromDate = new Timestamp(System.currentTimeMillis() - (days * 24L * 60 * 60 * 1000)) + + List headers = from("OrderHeader") + .where(EntityCondition.makeCondition([ + EntityCondition.makeCondition("orderTypeId", EntityOperator.EQUALS, "SALES_ORDER"), + EntityCondition.makeCondition("orderDate", EntityOperator.GREATER_THAN_EQUAL_TO, fromDate), + EntityCondition.makeCondition("statusId", EntityOperator.NOT_EQUAL, "ORDER_CANCELLED") + ], EntityOperator.AND)) + .select("orderId") + .queryList() + + if (!headers) { + return success([orderCount: 0, quantitySold: BigDecimal.ZERO, + totalRevenue: BigDecimal.ZERO, periodDays: days]) + } + + List orderIds = headers.collect { it.getString("orderId") } + + List items = from("OrderItem") + .where(EntityCondition.makeCondition([ + EntityCondition.makeCondition("productId", EntityOperator.EQUALS, productId), + EntityCondition.makeCondition("orderId", EntityOperator.IN, orderIds) + ], EntityOperator.AND)) + .select("orderId", "quantity", "unitPrice") + .queryList() + + BigDecimal qtyTotal = BigDecimal.ZERO + BigDecimal revTotal = BigDecimal.ZERO + Set seenOrders = [] + for (def item : items) { + BigDecimal qty = item.getBigDecimal("quantity") ?: BigDecimal.ZERO + BigDecimal price = item.getBigDecimal("unitPrice") ?: BigDecimal.ZERO + qtyTotal = qtyTotal.add(qty) + revTotal = revTotal.add(qty.multiply(price)) + seenOrders.add(item.getString("orderId")) + } + + return success([orderCount: seenOrders.size(), + quantitySold: qtyTotal, + totalRevenue: revTotal, + periodDays: days]) +} diff --git a/ai/groovyScripts/GetUsageSummary.groovy b/ai/groovyScripts/GetUsageSummary.groovy new file mode 100644 index 000000000..1ade04bd5 --- /dev/null +++ b/ai/groovyScripts/GetUsageSummary.groovy @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +Map serviceCtx = [userLogin: userLogin] +if (parameters.agentName) { + serviceCtx.agentName = parameters.agentName +} +if (parameters.userLoginId) { + serviceCtx.userLoginId = parameters.userLoginId +} +if (parameters.fromDate) { + serviceCtx.fromDate = parameters.fromDate +} +if (parameters.thruDate) { + serviceCtx.thruDate = parameters.thruDate +} + +Map result = dispatcher.runSync('getUsageSummary', serviceCtx) +if (org.apache.ofbiz.service.ServiceUtil.isError(result)) { + context.errorMessage = org.apache.ofbiz.service.ServiceUtil.getErrorMessage(result) + return +} + +context.totalRuns = result.totalRuns +context.totalInputTokens = result.totalInputTokens +context.totalOutputTokens = result.totalOutputTokens +context.estimatedCostUsd = result.estimatedCostUsd diff --git a/ai/groovyScripts/SetProductPromoPrice.groovy b/ai/groovyScripts/SetProductPromoPrice.groovy new file mode 100644 index 000000000..0cd3fbf34 --- /dev/null +++ b/ai/groovyScripts/SetProductPromoPrice.groovy @@ -0,0 +1,59 @@ +/* + * 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. + */ + +import org.apache.ofbiz.base.util.UtilDateTime +import java.sql.Timestamp + +def setProductPromoPrice() { + String productId = parameters.productId + BigDecimal promoPrice = parameters.promoPrice + int durationDays = parameters.durationDays ? parameters.durationDays as int : 7 + + if (!productId) return error("productId is required") + if (promoPrice == null || promoPrice <= BigDecimal.ZERO) { + return error("promoPrice must be a positive value") + } + + def product = from("Product").where("productId", productId).queryOne() + if (!product) return error("Product not found: ${productId}") + + Timestamp fromDate = UtilDateTime.nowTimestamp() + Timestamp thruDate = new Timestamp(fromDate.time + (durationDays * 24L * 60 * 60 * 1000)) + String loginId = userLogin?.getString("userLoginId") ?: "system" + + def priceRecord = delegator.makeValue("ProductPrice") + priceRecord.set("productId", productId) + priceRecord.set("productPriceTypeId", "SPECIAL_PROMO_PRICE") + priceRecord.set("productPricePurposeId", "PURCHASE") + priceRecord.set("currencyUomId", "USD") + priceRecord.set("productStoreGroupId", "_NA_") + priceRecord.set("fromDate", fromDate) + priceRecord.set("thruDate", thruDate) + priceRecord.set("price", promoPrice) + priceRecord.set("createdDate", fromDate) + priceRecord.set("createdByUserLogin", loginId) + priceRecord.set("lastModifiedDate", fromDate) + priceRecord.set("lastModifiedByUserLogin", loginId) + delegator.create(priceRecord) + + return success([confirmedProductId: productId, + confirmedPrice: promoPrice, + confirmedFromDate: fromDate.toString(), + confirmedThruDate: thruDate.toString()]) +} diff --git a/ai/ofbiz-component.xml b/ai/ofbiz-component.xml new file mode 100644 index 000000000..220e761e8 --- /dev/null +++ b/ai/ofbiz-component.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + diff --git a/ai/servicedef/services.xml b/ai/servicedef/services.xml new file mode 100644 index 000000000..cc9424030 --- /dev/null +++ b/ai/servicedef/services.xml @@ -0,0 +1,196 @@ + + + + + + Generate a text response from an AI model + + + + + + + Generate a structured Map response from an AI model + + + + + + + + Smoke test for the AI plugin — calls aiGenerate with a test message + + + + Smoke test for generateStructured — expects Map with word key + + + + Run a named AI agent with a user message and return the assistant response + + + + + + + + + + + + + Execute a pending agent proposal and resume the agent loop to completion + + + + + + + + Reject a pending agent proposal and obtain a graceful LLM acknowledgment + + + + + + + Archive a conversation thread so future agentRun calls with the same threadId start fresh + + + + + Return message history for a conversation thread + + + + + + Query AI agent run history and estimate token cost for a date range + + + + + + + + + + + + + + Return all active USD selling prices for a product: default, list, average cost, promo, competitive + + + + + + + + + + + + Return total available-to-promise quantity for a product across all facilities + + + + + + + Return order count, quantity sold, and revenue for a product over the last N days (default 30) + + + + + + + + + + Create a SPECIAL_PROMO_PRICE for a product in USD. Requires manager approval before execution. + + + + + + + + + + + + + Create a new AI Agent Definition + + + + + + + + + + Update an AI Agent Definition + + + + + + Grant a tool to an AI Agent + + + + + Revoke a tool grant from an AI Agent + + + + diff --git a/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java b/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java new file mode 100644 index 000000000..05576745f --- /dev/null +++ b/ai/src/main/java/org/apache/ofbiz/ai/AiAgentServices.java @@ -0,0 +1,482 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.ai; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.ofbiz.ai.agent.AgentDefinition; +import org.apache.ofbiz.ai.agent.AgentRunner; +import org.apache.ofbiz.ai.agent.AiChatClient; +import org.apache.ofbiz.ai.agent.AiHttpClient; +import org.apache.ofbiz.ai.agent.ProviderConfig; +import org.apache.ofbiz.ai.agent.ToolCatalog; +import org.apache.ofbiz.ai.agent.ToolDescriptor; +import org.apache.ofbiz.ai.container.AiContainer; +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.GeneralException; +import org.apache.ofbiz.base.util.UtilDateTime; +import org.apache.ofbiz.base.util.UtilValidate; +import org.apache.ofbiz.entity.Delegator; +import org.apache.ofbiz.entity.GenericEntityException; +import org.apache.ofbiz.entity.GenericValue; +import org.apache.ofbiz.entity.condition.EntityCondition; +import org.apache.ofbiz.entity.condition.EntityOperator; +import org.apache.ofbiz.entity.util.EntityQuery; +import org.apache.ofbiz.service.DispatchContext; +import org.apache.ofbiz.service.ServiceUtil; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +public class AiAgentServices { + + private static final String MODULE = AiAgentServices.class.getName(); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final TypeReference>> MSG_LIST_TYPE = + new TypeReference>>() { }; + private static final TypeReference> MAP_TYPE = + new TypeReference>() { }; + + public static Map agentRun(DispatchContext dctx, + Map context) { + String agentName = (String) context.get("agentName"); + String userMessage = (String) context.get("userMessage"); + GenericValue userLogin = (GenericValue) context.get("userLogin"); + + String threadId = (String) context.get("threadId"); + Boolean approvalRequired = (Boolean) context.get("approvalRequired"); + + if (UtilValidate.isEmpty(agentName)) { + return ServiceUtil.returnError("agentName is required"); + } + if (UtilValidate.isEmpty(userMessage)) { + return ServiceUtil.returnError("userMessage is required"); + } + + try { + AgentRunner runner = new AgentRunner(agentName, userMessage, userLogin, dctx); + if (threadId != null) { + runner.setThreadId(threadId); + } + if (Boolean.TRUE.equals(approvalRequired)) { + runner.setApprovalRequired(true); + } + AgentRunner.RunResult result = runner.run(); + Map serviceResult = ServiceUtil.returnSuccess(); + serviceResult.put("assistantMessage", result.getAssistantMessage()); + serviceResult.put("stopReason", result.getStopReason()); + serviceResult.put("iterationsUsed", result.getIterationsUsed()); + serviceResult.put("threadId", threadId); + serviceResult.put("proposalId", result.getProposalId()); + serviceResult.put("structuredResult", result.getStructuredResult()); + return serviceResult; + } catch (GeneralException e) { + Debug.logError(e, "agentRun failed for agent '" + agentName + "'", MODULE); + return ServiceUtil.returnError(safeMessage(e, "Agent run failed")); + } catch (Exception e) { + Debug.logError(e, "agentRun unexpected error for agent '" + agentName + "'", MODULE); + return ServiceUtil.returnError(safeMessage(e, "Agent run failed unexpectedly")); + } + } + + /** Returns e.getMessage() safely; falls back to the provided default if getMessage() itself throws. */ + private static String safeMessage(Exception e, String fallback) { + try { + String msg = e.getMessage(); + return UtilValidate.isNotEmpty(msg) ? msg : fallback; + } catch (Exception ignored) { + return fallback + " (" + e.getClass().getSimpleName() + ")"; + } + } + + public static Map approveAgentProposal(DispatchContext dctx, + Map context) { + Delegator delegator = dctx.getDelegator(); + String proposalId = (String) context.get("proposalId"); + GenericValue userLogin = (GenericValue) context.get("userLogin"); + + if (UtilValidate.isEmpty(proposalId)) { + return ServiceUtil.returnError("proposalId is required"); + } + try { + // Load and verify proposal is pending + GenericValue proposal = EntityQuery.use(delegator) + .from("AiAgentProposal").where("proposalId", proposalId).queryOne(); + if (proposal == null) { + return ServiceUtil.returnError("Proposal not found: " + proposalId); + } + if (!"AI_PROPOSAL_PENDING".equals(proposal.getString("statusId"))) { + return ServiceUtil.returnError("Proposal is not pending: current status is " + + proposal.getString("statusId")); + } + + // Mark approved + proposal.set("statusId", "AI_PROPOSAL_APPROVED"); + proposal.set("reviewedByUserLoginId", + userLogin != null ? userLogin.getString("userLoginId") : null); + proposal.set("reviewedAt", UtilDateTime.nowTimestamp()); + proposal.store(); + + // Deserialize messages + List> messages = OBJECT_MAPPER.readValue( + proposal.getString("messagesJson"), MSG_LIST_TYPE); + + // Load and execute pending tool calls, append results + List propTools = EntityQuery.use(delegator) + .from("AiAgentProposalTool") + .where("proposalId", proposalId) + .orderBy("proposalToolId") + .queryList(); + + ToolCatalog toolCatalog = AiContainer.getToolCatalog(); + + for (GenericValue propTool : propTools) { + String toolCallId = propTool.getString("toolCallId"); + String toolName = propTool.getString("toolName"); + String callArgsJson = propTool.getString("callArguments"); + + ToolDescriptor toolDesc = toolCatalog != null ? toolCatalog.getTool(toolName) : null; + if (toolDesc == null) { + Debug.logWarning("approveAgentProposal: tool '" + toolName + + "' not found in catalog, skipping", MODULE); + ObjectNode errNode = OBJECT_MAPPER.createObjectNode(); + errNode.put("error", "tool not found in catalog: " + toolName); + messages.add(toolRoleMessage(toolCallId, errNode.toString())); + continue; + } + + Map parsedArgs; + try { + parsedArgs = OBJECT_MAPPER.readValue(callArgsJson, MAP_TYPE); + } catch (Exception e) { + parsedArgs = new HashMap<>(); + } + Map ctx = new HashMap<>(parsedArgs); + ctx.put("userLogin", userLogin); + + String resultJson; + try { + Map toolResult = dctx.getDispatcher() + .runSync(toolDesc.getServiceName(), ctx); + if (ServiceUtil.isError(toolResult)) { + String errMsg = ServiceUtil.getErrorMessage(toolResult); + Debug.logWarning("approveAgentProposal: tool '" + toolName + + "' returned service error: " + errMsg, MODULE); + ObjectNode errNode = OBJECT_MAPPER.createObjectNode(); + errNode.put("error", errMsg); + resultJson = errNode.toString(); + } else { + resultJson = OBJECT_MAPPER.writeValueAsString(toolResult); + if (resultJson.length() > 8000) { + resultJson = resultJson.substring(0, 8000) + "...[truncated]"; + } + } + } catch (Exception e) { + Debug.logError(e, "approveAgentProposal: tool '" + toolName + + "' dispatch failed", MODULE); + ObjectNode errNode = OBJECT_MAPPER.createObjectNode(); + errNode.put("error", e.getMessage() != null ? e.getMessage() : "tool dispatch failed"); + resultJson = errNode.toString(); + } + + messages.add(toolRoleMessage(toolCallId, resultJson)); + } + + // Resume the agent loop + String agentName = proposal.getString("agentName"); + String runId = proposal.getString("runId"); + AgentRunner.RunResult result = AgentRunner.continueFromApproval( + agentName, messages, userLogin, dctx, runId); + + Map serviceResult = ServiceUtil.returnSuccess(); + serviceResult.put("assistantMessage", result.getAssistantMessage()); + serviceResult.put("stopReason", result.getStopReason()); + serviceResult.put("iterationsUsed", result.getIterationsUsed()); + return serviceResult; + + } catch (GeneralException e) { + Debug.logError(e, "approveAgentProposal failed", MODULE); + return ServiceUtil.returnError(e.getMessage()); + } catch (Exception e) { + Debug.logError(e, "approveAgentProposal unexpected error", MODULE); + return ServiceUtil.returnError(e.getMessage()); + } + } + + public static Map rejectAgentProposal(DispatchContext dctx, + Map context) { + Delegator delegator = dctx.getDelegator(); + String proposalId = (String) context.get("proposalId"); + String rejectionReason = (String) context.get("rejectionReason"); + GenericValue userLogin = (GenericValue) context.get("userLogin"); + + if (UtilValidate.isEmpty(proposalId)) { + return ServiceUtil.returnError("proposalId is required"); + } + try { + // Load and verify proposal is pending + GenericValue proposal = EntityQuery.use(delegator) + .from("AiAgentProposal").where("proposalId", proposalId).queryOne(); + if (proposal == null) { + return ServiceUtil.returnError("Proposal not found: " + proposalId); + } + if (!"AI_PROPOSAL_PENDING".equals(proposal.getString("statusId"))) { + return ServiceUtil.returnError("Proposal is not pending: current status is " + + proposal.getString("statusId")); + } + + // Mark rejected + proposal.set("statusId", "AI_PROPOSAL_REJECTED"); + proposal.set("reviewedByUserLoginId", + userLogin != null ? userLogin.getString("userLoginId") : null); + proposal.set("reviewedAt", UtilDateTime.nowTimestamp()); + if (UtilValidate.isNotEmpty(rejectionReason)) { + proposal.set("rejectionReason", rejectionReason); + } + proposal.store(); + + // Deserialize messages and append rejection + List> messages = OBJECT_MAPPER.readValue( + proposal.getString("messagesJson"), MSG_LIST_TYPE); + + String reason = UtilValidate.isNotEmpty(rejectionReason) + ? rejectionReason : "No reason provided."; + Map rejectionMsg = new LinkedHashMap<>(); + rejectionMsg.put("role", "user"); + rejectionMsg.put("content", + "The proposed actions have been rejected by a human reviewer. Reason: " + + reason + " Please acknowledge and provide a helpful response."); + messages.add(rejectionMsg); + + // Single LLM call for acknowledgment + if (AiContainer.getAgentRegistry() == null) { + return ServiceUtil.returnError("AgentRegistry not available"); + } + AgentDefinition agentDef = AiContainer.getAgentRegistry() + .getAgent(proposal.getString("agentName")); + if (agentDef == null) { + return ServiceUtil.returnError("Agent not found: " + proposal.getString("agentName")); + } + if (AiContainer.getProviderRegistry() == null) { + return ServiceUtil.returnError("ProviderRegistry not available"); + } + ProviderConfig provider = AiContainer.getProviderRegistry() + .getProvider(agentDef.getProviderName()); + if (provider == null) { + return ServiceUtil.returnError("Provider not configured: " + agentDef.getProviderName()); + } + + AiChatClient client = new AiHttpClient(); + AiChatClient.ChatResponse response = client.chat( + Collections.unmodifiableList(messages), + Collections.emptyList(), + agentDef.getModelOverride(), + provider, + null); + + Map serviceResult = ServiceUtil.returnSuccess(); + serviceResult.put("assistantMessage", response.getContent()); + return serviceResult; + + } catch (GeneralException e) { + Debug.logError(e, "rejectAgentProposal failed", MODULE); + return ServiceUtil.returnError(e.getMessage()); + } catch (Exception e) { + Debug.logError(e, "rejectAgentProposal unexpected error", MODULE); + return ServiceUtil.returnError(e.getMessage()); + } + } + + public static Map archiveConversationThread(DispatchContext dctx, + Map context) { + Delegator delegator = dctx.getDelegator(); + String threadId = (String) context.get("threadId"); + if (UtilValidate.isEmpty(threadId)) { + return ServiceUtil.returnError("threadId is required"); + } + try { + GenericValue thread = EntityQuery.use(delegator) + .from("AiConversationThread") + .where("threadId", threadId) + .queryOne(); + if (thread == null) { + return ServiceUtil.returnError("Thread not found: " + threadId); + } + thread.set("statusId", "AI_THREAD_ARCHIVED"); + thread.store(); + return ServiceUtil.returnSuccess(); + } catch (GenericEntityException e) { + Debug.logError(e, "archiveConversationThread failed", MODULE); + return ServiceUtil.returnError(e.getMessage()); + } + } + + public static Map getConversationHistory(DispatchContext dctx, + Map context) { + Delegator delegator = dctx.getDelegator(); + String threadId = (String) context.get("threadId"); + try { + List rows = EntityQuery.use(delegator) + .from("AiConversationMessage") + .where("threadId", threadId) + .orderBy("sequenceNum") + .queryList(); + List> messages = new ArrayList<>(); + for (GenericValue row : rows) { + Map msg = new LinkedHashMap<>(); + msg.put("role", row.getString("role")); + msg.put("content", row.getString("content")); + msg.put("sequenceNum", row.getLong("sequenceNum")); + messages.add(msg); + } + Map result = ServiceUtil.returnSuccess(); + result.put("messages", messages); + return result; + } catch (GenericEntityException e) { + Debug.logError(e, "getConversationHistory failed", MODULE); + return ServiceUtil.returnError(e.getMessage()); + } + } + + public static Map getUsageSummary(DispatchContext dctx, + Map context) { + Delegator delegator = dctx.getDelegator(); + String agentNameFilter = (String) context.get("agentName"); + String userLoginId = (String) context.get("userLoginId"); + Timestamp fromDate = (Timestamp) context.get("fromDate"); + Timestamp thruDate = (Timestamp) context.get("thruDate"); + + try { + // Build conditions for completed runs only + List conditions = new ArrayList<>(); + conditions.add(EntityCondition.makeCondition("statusId", + EntityOperator.EQUALS, "AI_RUN_COMPLETED")); + if (UtilValidate.isNotEmpty(agentNameFilter)) { + conditions.add(EntityCondition.makeCondition("agentName", + EntityOperator.EQUALS, agentNameFilter)); + } + if (UtilValidate.isNotEmpty(userLoginId)) { + conditions.add(EntityCondition.makeCondition("userLoginId", + EntityOperator.EQUALS, userLoginId)); + } + if (fromDate != null) { + conditions.add(EntityCondition.makeCondition("startedAt", + EntityOperator.GREATER_THAN_EQUAL_TO, fromDate)); + } + if (thruDate != null) { + conditions.add(EntityCondition.makeCondition("startedAt", + EntityOperator.LESS_THAN_EQUAL_TO, thruDate)); + } + EntityCondition cond = conditions.size() == 1 + ? conditions.get(0) + : EntityCondition.makeCondition(conditions, EntityOperator.AND); + + List runs = EntityQuery.use(delegator) + .from("AiAgentRun") + .where(cond) + .queryList(); + + long totalRuns = runs.size(); + long totalInputTokens = 0L; + long totalOutputTokens = 0L; + String lastAgentNameSeen = null; + for (GenericValue run : runs) { + if (run.getLong("inputTokens") != null) { + totalInputTokens += run.getLong("inputTokens"); + } + if (run.getLong("outputTokens") != null) { + totalOutputTokens += run.getLong("outputTokens"); + } + lastAgentNameSeen = run.getString("agentName"); + } + + // Resolve effective agent name for cost lookup + String effectiveAgentName = UtilValidate.isNotEmpty(agentNameFilter) + ? agentNameFilter : lastAgentNameSeen; + + // Look up cost by resolving model from the agent definition + BigDecimal estimatedCostUsd = null; + if (effectiveAgentName != null && AiContainer.getAgentRegistry() != null) { + AgentDefinition agentDef = AiContainer.getAgentRegistry().getAgent(effectiveAgentName); + if (agentDef != null) { + ProviderConfig provider = AiContainer.getProviderRegistry() != null + ? AiContainer.getProviderRegistry().getProvider(agentDef.getProviderName()) + : null; + String modelId = agentDef.getModelOverride() != null + ? agentDef.getModelOverride() + : (provider != null ? provider.getModel() : null); + if (modelId != null) { + GenericValue costRow = EntityQuery.use(delegator) + .from("AiProviderCost") + .where("modelId", modelId) + .orderBy("-effectiveDate") + .queryFirst(); + if (costRow != null) { + BigDecimal inputCost = costRow.getBigDecimal("inputCostPerMillion"); + BigDecimal outputCost = costRow.getBigDecimal("outputCostPerMillion"); + if (inputCost != null && outputCost != null) { + BigDecimal million = new BigDecimal("1000000"); + estimatedCostUsd = inputCost + .multiply(BigDecimal.valueOf(totalInputTokens)) + .divide(million, 6, RoundingMode.HALF_UP) + .add(outputCost + .multiply(BigDecimal.valueOf(totalOutputTokens)) + .divide(million, 6, RoundingMode.HALF_UP)); + } + } + } + } + } + + if (estimatedCostUsd == null && totalRuns > 0) { + Debug.logWarning("getUsageSummary: " + totalRuns + " run(s) found but cost could not" + + " be estimated — agent/provider/model not resolved or not in AiProviderCost", + MODULE); + } + + Map result = ServiceUtil.returnSuccess(); + result.put("totalRuns", totalRuns); + result.put("totalInputTokens", totalInputTokens); + result.put("totalOutputTokens", totalOutputTokens); + result.put("estimatedCostUsd", estimatedCostUsd); + return result; + + } catch (GenericEntityException e) { + Debug.logError(e, "getUsageSummary failed", MODULE); + return ServiceUtil.returnError(e.getMessage()); + } + } + + private static Map toolRoleMessage(String toolCallId, String content) { + Map msg = new LinkedHashMap<>(); + msg.put("role", "tool"); + msg.put("tool_call_id", toolCallId); + msg.put("content", content); + return msg; + } +} diff --git a/ai/src/main/java/org/apache/ofbiz/ai/AiServices.java b/ai/src/main/java/org/apache/ofbiz/ai/AiServices.java new file mode 100644 index 000000000..47fb8207d --- /dev/null +++ b/ai/src/main/java/org/apache/ofbiz/ai/AiServices.java @@ -0,0 +1,60 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.ai; + +import java.util.List; +import java.util.Map; + +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.GeneralException; +import org.apache.ofbiz.base.util.UtilGenerics; +import org.apache.ofbiz.service.DispatchContext; +import org.apache.ofbiz.service.ServiceUtil; + +public class AiServices { + + private static final String MODULE = AiServices.class.getName(); + + public static Map generate(DispatchContext dctx, Map context) { + List> messages = UtilGenerics.cast(context.get("messages")); + try { + String response = AiWorker.generate(dctx, messages); + Map result = ServiceUtil.returnSuccess(); + result.put("response", response); + return result; + } catch (GeneralException e) { + Debug.logError(e, e.getMessage(), MODULE); + return ServiceUtil.returnError(e.getMessage()); + } + } + + public static Map generateStructured(DispatchContext dctx, Map context) { + List> messages = UtilGenerics.cast(context.get("messages")); + Map schema = UtilGenerics.cast(context.get("schema")); + try { + Map aiResult = AiWorker.generateStructured(dctx, messages, schema); + Map result = ServiceUtil.returnSuccess(); + result.put("result", aiResult); + return result; + } catch (GeneralException e) { + Debug.logError(e, e.getMessage(), MODULE); + return ServiceUtil.returnError(e.getMessage()); + } + } +} diff --git a/ai/src/main/java/org/apache/ofbiz/ai/AiWorker.java b/ai/src/main/java/org/apache/ofbiz/ai/AiWorker.java new file mode 100644 index 000000000..30973778e --- /dev/null +++ b/ai/src/main/java/org/apache/ofbiz/ai/AiWorker.java @@ -0,0 +1,163 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.ai; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.apache.ofbiz.ai.agent.AiChatClient; +import org.apache.ofbiz.ai.agent.AiHttpClient; +import org.apache.ofbiz.ai.agent.ProviderConfig; +import org.apache.ofbiz.ai.container.AiContainer; +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.GeneralException; +import org.apache.ofbiz.base.util.UtilValidate; +import org.apache.ofbiz.service.DispatchContext; + +/** + * Utility class providing simple LLM call helpers used by the + * {@code ai.generate} and {@code ai.generateStructured} OFBiz services. + * + *

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

Provider lookup order: + *

    + *
  1. Provider named {@value #DEFAULT_PROVIDER} in {@code ai.properties}.
  2. + *
  3. First available provider if {@value #DEFAULT_PROVIDER} is not configured.
  4. + *
+ * + * @param dctx the dispatch context (unused directly, retained for API symmetry) + * @param messages ordered list of role/content message maps + * @return the assistant's text, or a human-readable error string if the AI + * service is not configured + * @throws GeneralException if the HTTP request fails or the response cannot be parsed + */ + public static String generate(DispatchContext dctx, + List> messages) throws GeneralException { + 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

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

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

The three registries are held as static fields so that service Groovy scripts and + * {@link org.apache.ofbiz.ai.agent.AgentRunner} can access them via the static getters + * without requiring a container reference. + */ +public class AiContainer implements Container { + + private static final String MODULE = AiContainer.class.getName(); + + private static 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 + localDispatcherNameai + + + The Name of the Entity Delegator to use, defined in entityengine.xml + entityDelegatorNamedefault + + + The location of the main-decorator screen to use for this webapp; referred to as a context variable in screen def XML files. + mainDecoratorLocation + component://ai/widget/ai/CommonScreens.xml + + + The location of the menus file to be used in this webapp; referred to as a context variable in screen def XML files. + mainMenuLocation + component://ai/widget/ai/AiMenus.xml + + + Remove unnecessary whitespace from HTML output. + compressHTML + false + + + + ControlFilter + ControlFilter + org.apache.ofbiz.webapp.control.ControlFilter + + allowedPaths + /error:/control:/select:/index.html:/index.jsp:/default.html:/default.jsp:/images:/js:/ws + + + redirectPath + /control/main + + + + ContextFilter + ContextFilter + org.apache.ofbiz.webapp.control.ContextFilter + + + SameSiteFilter + SameSiteFilter + org.apache.ofbiz.webapp.control.SameSiteFilter + + + ControlFilter + /* + + + ContextFilter + /* + + + SameSiteFilter + /* + + + org.apache.ofbiz.webapp.control.ControlEventListener + org.apache.ofbiz.webapp.control.LoginEventListener + + + Main Control Servlet + ControlServlet + ControlServlet + org.apache.ofbiz.webapp.control.ControlServlet + 1 + + ControlServlet/control/* + + + index.jsp + index.html + index.htm + + diff --git a/ai/webapp/ai/index.jsp b/ai/webapp/ai/index.jsp new file mode 100644 index 000000000..0ad9a3f91 --- /dev/null +++ b/ai/webapp/ai/index.jsp @@ -0,0 +1,20 @@ +<%-- +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +--%> + +<%response.sendRedirect("control/main");%> diff --git a/ai/widget/ai/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 @@ + + + + + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + +
+ + +
+ + + + + + +
+ +
+ + + + + + + + + + + + +
+ + +
+ + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + +