597a142f by Ean Schuessler

Add agent runtime infrastructure

- AgentEntities.xml: Entity extensions for agent tasks
- AgentServices.xml: Agent runner, client, and queue services
- AgentData.xml: Scheduled job, user, and AI config seed data
1 parent ba87ad1f
<?xml version="1.0" encoding="UTF-8"?>
<entity-facade-xml xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/entity-facade-3.xsd" type="seed">
<!-- ========================================================= -->
<!-- Agent Runtime Scheduled Job -->
<!-- ========================================================= -->
<moqui.service.ScheduledJob
jobName="AgentQueuePoller"
description="Polls Agent Queue and processes pending tasks"
serviceName="AgentServices.poll#AgentQueue"
cronExpression="0/30 * * * * ?"
runAsUser="ADMIN"
paused="N"/>
<!-- ========================================================= -->
<!-- Agent User Account (for authentication) -->
<!-- ========================================================= -->
<moqui.security.UserAccount
userId="AGENT_CLAUDE"
username="agent-claude"
currentPassword="16ac58bbfa332c1c55bd98b53e60720bfa90d394"
passwordHashType="SHA"
enabled="Y"
description="Agent user for AI runtime"/>
<moqui.security.UserGroup userGroupId="AgentUsers" description="AI Agent Users"/>
<moqui.security.UserGroupMember userGroupId="AgentUsers" userId="AGENT_CLAUDE" fromDate="2026-02-04 00:00:00.000"/>
<!-- Agent users have permission to execute the delegation service -->
<moqui.security.ArtifactGroup artifactGroupId="AgentDelegationServices" description="Agent Tool Delegation Services"/>
<moqui.security.ArtifactGroupMember artifactGroupId="AgentDelegationServices" artifactName="AgentServices.call#McpToolWithDelegation" artifactTypeEnumId="AT_SERVICE"/>
<moqui.security.ArtifactAuthz userGroupId="AgentUsers" artifactGroupId="AgentDelegationServices" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/>
<!-- ========================================================= -->
<!-- Sample AI Configuration (for testing) -->
<!-- ========================================================= -->
<!-- Using localhost:11434 for Ollama (if available) -->
<!-- Or configure for your VLLM/OpenAI endpoint -->
<moqui.mcp.agent.ProductStoreAiConfig
productStoreId="POPCOMMERCE_RETAIL"
aiConfigId="DEFAULT"
serviceTypeEnumId="AistOllama"
description="Default AI Config for Testing (Ollama)"
endpointUrl="http://localhost:11434/v1"
modelName="llama3.2:3b"
temperature="0.7"
maxTokens="2048"
/>
</entity-facade-xml>
<?xml version="1.0" encoding="UTF-8"?>
<entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/entity-definition-3.xsd">
<!-- ========================================================= -->
<!-- System Message Extensions for Agent Tasks -->
<!-- ========================================================= -->
<extend-entity entity-name="SystemMessage" package="moqui.service.message">
<field name="requestedByPartyId" type="id">
<description>The human user (Party) who requested this agent task.</description>
</field>
<field name="effectiveUserId" type="id">
<description>The UserAccount ID to impersonate during execution (RBAC context).</description>
</field>
<field name="productStoreId" type="id">
<description>The context ProductStore for this task (determines AI config).</description>
</field>
<field name="aiConfigId" type="id">
<description>Specific AI configuration used for this task.</description>
</field>
<relationship type="one" title="RequestedBy" related="mantle.party.Party">
<key-map field-name="requestedByPartyId" related="partyId"/>
</relationship>
<!-- Note: effectiveUserId links to UserAccount, but we don't force FK to allow system users -->
<relationship type="one" related="mantle.product.store.ProductStore">
<key-map field-name="productStoreId"/>
</relationship>
<relationship type="one" related="moqui.mcp.agent.ProductStoreAiConfig">
<key-map field-name="productStoreId"/>
<key-map field-name="aiConfigId"/>
</relationship>
</extend-entity>
<!-- ========================================================= -->
<!-- AI Gateway Configuration (Per Store) -->
<!-- ========================================================= -->
<entity entity-name="ProductStoreAiConfig" package="moqui.mcp.agent">
<description>Configures AI Service Providers (OpenAI, VLLM, etc.) per Product Store.</description>
<field name="productStoreId" type="id" is-pk="true"/>
<field name="aiConfigId" type="id" is-pk="true"/>
<field name="serviceTypeEnumId" type="id"/>
<field name="description" type="text-medium"/>
<!-- Connection Details -->
<field name="endpointUrl" type="text-medium"/>
<field name="apiKey" type="text-medium" encrypt="true"/>
<field name="organizationId" type="text-short"/>
<!-- Model Parameters -->
<field name="modelName" type="text-short"/>
<field name="temperature" type="number-decimal"/>
<field name="maxTokens" type="number-integer"/>
<!-- System Prompt Template -->
<field name="systemMessageId" type="id">
<description>Template for the system prompt (instruction set)</description>
</field>
<relationship type="one" related="mantle.product.store.ProductStore">
<key-map field-name="productStoreId"/>
</relationship>
<relationship type="one" title="AiServiceType" related="moqui.basic.Enumeration">
<key-map field-name="serviceTypeEnumId" related="enumId"/>
</relationship>
<relationship type="one" title="PromptTemplate" related="moqui.service.message.SystemMessage">
<key-map field-name="systemMessageId"/>
</relationship>
<seed-data>
<!-- AI Service Types -->
<moqui.basic.EnumerationType description="AI Service Type" enumTypeId="AiServiceType"/>
<moqui.basic.Enumeration enumId="AistOpenAi" description="OpenAI" enumTypeId="AiServiceType"/>
<moqui.basic.Enumeration enumId="AistVllm" description="VLLM (OpenAI Compatible)" enumTypeId="AiServiceType"/>
<moqui.basic.Enumeration enumId="AistAnthropic" description="Anthropic" enumTypeId="AiServiceType"/>
<moqui.basic.Enumeration enumId="AistOllama" description="Ollama" enumTypeId="AiServiceType"/>
<!-- Agent Task Message Type -->
<moqui.basic.Enumeration enumId="SmtyAgentTask" description="Agent Task" enumTypeId="SystemMessageType"/>
<!-- AI Product Store Settings -->
<moqui.basic.Enumeration enumId="AiEndpointUrl" description="AI Endpoint URL" enumTypeId="ProductStoreSettingType"/>
<moqui.basic.Enumeration enumId="AiApiKey" description="AI API Key" enumTypeId="ProductStoreSettingType"/>
<moqui.basic.Enumeration enumId="AiModelName" description="AI Model Name" enumTypeId="ProductStoreSettingType"/>
<moqui.basic.Enumeration enumId="AiTemperature" description="AI Temperature" enumTypeId="ProductStoreSettingType"/>
</seed-data>
</entity>
</entities>
<?xml version="1.0" encoding="UTF-8"?>
<services xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/service-definition-3.xsd">
<!-- ========================================================= -->
<!-- Agent Tool Bridge (The Secure Gateway) -->
<!-- ========================================================= -->
<service verb="call" noun="McpToolWithDelegation">
<description>
Securely executes an MCP tool by impersonating the target user (runAsUserId).
The calling agent must have permission to use this service, but the
tool execution itself is subject to the target user's permissions.
</description>
<in-parameters>
<parameter name="toolName" required="true"/>
<parameter name="arguments" type="Map"/>
<parameter name="runAsUserId" required="true">
<description>The UserAccount ID to impersonate.</description>
</parameter>
</in-parameters>
<out-parameters>
<parameter name="result" type="Map"/>
</out-parameters>
<actions>
<script><![CDATA[
import org.moqui.mcp.adapter.McpToolAdapter
import org.moqui.context.ArtifactAuthorizationException
// 1. Capture current agent identity
String agentUsername = ec.user.username
try {
// 2. Switch identity to target user
// 'false' arg means don't trigger history/visit updates for this switch
boolean loggedIn = ec.user.internalLoginUser(runAsUserId, false)
if (!loggedIn) throw new Exception("Could not switch to user ${runAsUserId}")
ec.logger.info("Agent ${agentUsername} executing ${toolName} AS ${ec.user.username} (${runAsUserId})")
// 3. Execute Tool (Standard RBAC applies to this user)
McpToolAdapter adapter = new McpToolAdapter()
// The adapter MUST NOT disableAuthz internally for this to be secure
result = adapter.callTool(ec, toolName, arguments)
} finally {
// 4. Restore Agent Identity
if (agentUsername) {
ec.user.internalLoginUser(agentUsername, false)
}
}
]]></script>
</actions>
</service>
<!-- ========================================================= -->
<!-- Agent Client (OpenAI-Compatible API Wrapper) -->
<!-- ========================================================= -->
<service verb="call" noun="OpenAiChatCompletion">
<description>Generic wrapper for OpenAI-compatible chat completions (VLLM, OpenAI, etc.)</description>
<in-parameters>
<parameter name="endpointUrl" required="true"/>
<parameter name="apiKey"/>
<parameter name="model" required="true"/>
<parameter name="messages" type="List" required="true"/>
<parameter name="tools" type="List"/>
<parameter name="temperature" type="BigDecimal" default="0.7"/>
<parameter name="maxTokens" type="Integer"/>
</in-parameters>
<out-parameters>
<parameter name="response" type="Map"/>
<parameter name="httpStatus" type="Integer"/>
<parameter name="error" type="String"/>
</out-parameters>
<actions>
<script><![CDATA[
import groovy.json.JsonBuilder
import groovy.json.JsonSlurper
// Construct payload
def payloadMap = [
model: model,
messages: messages,
temperature: temperature,
stream: false
]
if (maxTokens) payloadMap.maxTokens = maxTokens
if (tools) payloadMap.tools = tools
String jsonPayload = new JsonBuilder(payloadMap).toString()
// Setup connection
URL url = new URL(endpointUrl + "/chat/completions")
HttpURLConnection conn = (HttpURLConnection) url.openConnection()
conn.setRequestMethod("POST")
conn.setRequestProperty("Content-Type", "application/json")
if (apiKey) conn.setRequestProperty("Authorization", "Bearer " + apiKey)
conn.setDoOutput(true)
conn.setConnectTimeout(10000) // 10s connect
conn.setReadTimeout(60000) // 60s read (LLMs are slow)
try {
conn.outputStream.write(jsonPayload.getBytes("UTF-8"))
httpStatus = conn.responseCode
InputStream is = (httpStatus >= 200 && httpStatus < 300) ? conn.inputStream : conn.errorStream
String responseText = is?.text
if (responseText) {
response = new JsonSlurper().parseText(responseText)
}
if (httpStatus >= 300) {
error = "HTTP ${httpStatus}: ${responseText}"
ec.logger.error("OpenAI Client Error: ${error}")
}
} catch (Exception e) {
error = e.message
httpStatus = 500
ec.logger.error("OpenAI Client Exception", e)
}
]]></script>
</actions>
</service>
<!-- ========================================================= -->
<!-- Agent Runner (The Loop) -->
<!-- ========================================================= -->
<service verb="run" noun="AgentTask">
<description>
Processes a single Agent Task SystemMessage.
Handles the loop of: Prompt -> LLM -> Tool Call -> Tool Execution -> Prompt.
</description>
<in-parameters>
<parameter name="systemMessageId" required="true"/>
</in-parameters>
<actions>
<script><![CDATA[
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
// 1. Load SystemMessage and Config
def taskMsg = ec.entity.find("moqui.service.message.SystemMessage")
.condition("systemMessageId", systemMessageId)
.one()
if (!taskMsg) return
// Get AI Config
def aiConfig = ec.entity.find("moqui.mcp.agent.ProductStoreAiConfig")
.condition("productStoreId", taskMsg.productStoreId)
.condition("aiConfigId", taskMsg.aiConfigId)
.one()
// Fallback to ProductStoreSetting if no specific AI Config found
def endpointUrl, apiKey, modelName, temperature
if (aiConfig) {
endpointUrl = aiConfig.endpointUrl
apiKey = aiConfig.apiKey
modelName = aiConfig.modelName
temperature = aiConfig.temperature
} else if (taskMsg.productStoreId) {
// Try ProductStoreSettings
def settings = ec.entity.find("mantle.product.store.ProductStoreSetting")
.condition("productStoreId", taskMsg.productStoreId)
.condition("settingTypeEnumId", ["AiEndpointUrl", "AiApiKey", "AiModelName", "AiTemperature"])
.list()
endpointUrl = settings.find { it.settingTypeEnumId == "AiEndpointUrl" }?.settingValue
apiKey = settings.find { it.settingTypeEnumId == "AiApiKey" }?.settingValue
modelName = settings.find { it.settingTypeEnumId == "AiModelName" }?.settingValue
temperature = settings.find { it.settingTypeEnumId == "AiTemperature" }?.settingValue?.toBigDecimal()
}
if (!endpointUrl || !modelName) {
ec.logger.error("No AI Configuration (Entity or Settings) found for task ${systemMessageId}")
taskMsg.statusId = "SmsError"
taskMsg.messageText = "Missing AI Configuration (Endpoint or Model)"
taskMsg.update()
return
}
// Default temperature if missing
if (temperature == null) temperature = 0.7
// 2. Prepare Tools (Convert MCP tools to OpenAI format)
def mcpToolAdapter = new org.moqui.mcp.adapter.McpToolAdapter()
def moquiTools = mcpToolAdapter.listTools()
// Filter out dangerous tools if needed? For now, we rely on RBAC delegation.
def openAiTools = moquiTools.collect { tool ->
[
type: "function",
function: [
name: tool.name,
description: tool.description,
// Helper to build schema (simplified for now, ideally strictly typed)
parameters: [
type: "object",
properties: [
path: [type: "string", description: "Screen path or resource URI"],
action: [type: "string", description: "Action to perform (create, update, etc)"],
parameters: [type: "object", description: "Key-value pairs for the action"]
]
]
]
]
}
// 3. Build Conversation History
// TODO: Load history if this is a continuation. For now, simple start.
def messages = [
[role: "system", content: "You are a helpful Moqui ERP assistant. You act as user ${taskMsg.requestedByUserId}."],
[role: "user", content: taskMsg.messageText]
]
// 4. The Loop (Max 5 turns for safety)
int maxTurns = 5
int currentTurn = 0
boolean taskComplete = false
while (currentTurn < maxTurns && !taskComplete) {
currentTurn++
// Call LLM
def llmResult = ec.service.sync().name("AgentServices.call#OpenAiChatCompletion").parameters([
endpointUrl: aiConfig.endpointUrl,
apiKey: aiConfig.apiKey, // Decrypt if needed
model: aiConfig.modelName,
messages: messages,
tools: openAiTools,
temperature: aiConfig.temperature
]).call()
if (llmResult.error) {
taskMsg.statusId = "SmsError"
taskMsg.messageText += "\nError: ${llmResult.error}"
taskMsg.update()
return
}
def responseMsg = llmResult.response.choices[0].message
messages.add(responseMsg) // Add assistant response to history
// Check for Tool Calls
if (responseMsg.tool_calls) {
ec.logger.info("Agent requesting ${responseMsg.tool_calls.size()} tools")
responseMsg.tool_calls.each { toolCall ->
def functionName = toolCall.function.name
def functionArgs = new JsonSlurper().parseText(toolCall.function.arguments)
def toolCallId = toolCall.id
// EXECUTE TOOL via Secure Bridge
def executionResult = [:]
try {
def runResult = ec.service.sync().name("AgentServices.call#McpToolWithDelegation").parameters([
toolName: functionName,
arguments: functionArgs,
runAsUserId: taskMsg.effectiveUserId // DELEGATION!
]).call()
executionResult = runResult.result
} catch (Exception e) {
executionResult = [error: e.message]
}
// Add result to history
messages.add([
role: "tool",
tool_call_id: toolCallId,
content: JsonOutput.toJson(executionResult)
])
}
// Loop continues to let LLM see results
} else {
// No tool calls = Final Response
taskComplete = true
taskMsg.statusId = "SmsProcessed"
taskMsg.messageText += "\n\n=== RESPONSE ===\n${responseMsg.content}"
taskMsg.update()
}
}
]]></script>
</actions>
</service>
<!-- ========================================================= -->
<!-- Task Scheduler (Polls Queue) -->
<!-- ========================================================= -->
<service verb="poll" noun="AgentQueue">
<description>Scheduled service to pick up pending tasks.</description>
<actions>
<script><![CDATA[
import org.moqui.entity.EntityCondition
// Find pending tasks
def pendingTasks = ec.entity.find("moqui.service.message.SystemMessage")
.condition("statusId", "SmsReceived") // Or generic 'Pending'
.condition("systemMessageTypeId", "SmtyAgentTask")
.limit(5) // Batch size
.disableAuthz() // System service needs to see all tasks
.list()
pendingTasks.each { task ->
// Mark as In Progress
task.statusId = "SmsConsumed" // Or 'In Progress'
task.update()
// Run Async
ec.service.async().name("AgentServices.run#AgentTask")
.parameters([systemMessageId: task.systemMessageId])
.call()
}
]]></script>
</actions>
</service>
</services>