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
1 <?xml version="1.0" encoding="UTF-8"?>
2 <entity-facade-xml xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3 xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/entity-facade-3.xsd" type="seed">
4
5 <!-- ========================================================= -->
6 <!-- Agent Runtime Scheduled Job -->
7 <!-- ========================================================= -->
8
9 <moqui.service.ScheduledJob
10 jobName="AgentQueuePoller"
11 description="Polls Agent Queue and processes pending tasks"
12 serviceName="AgentServices.poll#AgentQueue"
13 cronExpression="0/30 * * * * ?"
14 runAsUser="ADMIN"
15 paused="N"/>
16
17 <!-- ========================================================= -->
18 <!-- Agent User Account (for authentication) -->
19 <!-- ========================================================= -->
20
21 <moqui.security.UserAccount
22 userId="AGENT_CLAUDE"
23 username="agent-claude"
24 currentPassword="16ac58bbfa332c1c55bd98b53e60720bfa90d394"
25 passwordHashType="SHA"
26 enabled="Y"
27 description="Agent user for AI runtime"/>
28
29 <moqui.security.UserGroup userGroupId="AgentUsers" description="AI Agent Users"/>
30 <moqui.security.UserGroupMember userGroupId="AgentUsers" userId="AGENT_CLAUDE" fromDate="2026-02-04 00:00:00.000"/>
31
32 <!-- Agent users have permission to execute the delegation service -->
33 <moqui.security.ArtifactGroup artifactGroupId="AgentDelegationServices" description="Agent Tool Delegation Services"/>
34 <moqui.security.ArtifactGroupMember artifactGroupId="AgentDelegationServices" artifactName="AgentServices.call#McpToolWithDelegation" artifactTypeEnumId="AT_SERVICE"/>
35 <moqui.security.ArtifactAuthz userGroupId="AgentUsers" artifactGroupId="AgentDelegationServices" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/>
36
37 <!-- ========================================================= -->
38 <!-- Sample AI Configuration (for testing) -->
39 <!-- ========================================================= -->
40
41 <!-- Using localhost:11434 for Ollama (if available) -->
42 <!-- Or configure for your VLLM/OpenAI endpoint -->
43 <moqui.mcp.agent.ProductStoreAiConfig
44 productStoreId="POPCOMMERCE_RETAIL"
45 aiConfigId="DEFAULT"
46 serviceTypeEnumId="AistOllama"
47 description="Default AI Config for Testing (Ollama)"
48 endpointUrl="http://localhost:11434/v1"
49 modelName="llama3.2:3b"
50 temperature="0.7"
51 maxTokens="2048"
52 />
53
54 </entity-facade-xml>
1 <?xml version="1.0" encoding="UTF-8"?>
2 <entities xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3 xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/entity-definition-3.xsd">
4
5 <!-- ========================================================= -->
6 <!-- System Message Extensions for Agent Tasks -->
7 <!-- ========================================================= -->
8
9 <extend-entity entity-name="SystemMessage" package="moqui.service.message">
10 <field name="requestedByPartyId" type="id">
11 <description>The human user (Party) who requested this agent task.</description>
12 </field>
13 <field name="effectiveUserId" type="id">
14 <description>The UserAccount ID to impersonate during execution (RBAC context).</description>
15 </field>
16 <field name="productStoreId" type="id">
17 <description>The context ProductStore for this task (determines AI config).</description>
18 </field>
19 <field name="aiConfigId" type="id">
20 <description>Specific AI configuration used for this task.</description>
21 </field>
22
23 <relationship type="one" title="RequestedBy" related="mantle.party.Party">
24 <key-map field-name="requestedByPartyId" related="partyId"/>
25 </relationship>
26 <!-- Note: effectiveUserId links to UserAccount, but we don't force FK to allow system users -->
27 <relationship type="one" related="mantle.product.store.ProductStore">
28 <key-map field-name="productStoreId"/>
29 </relationship>
30 <relationship type="one" related="moqui.mcp.agent.ProductStoreAiConfig">
31 <key-map field-name="productStoreId"/>
32 <key-map field-name="aiConfigId"/>
33 </relationship>
34 </extend-entity>
35
36 <!-- ========================================================= -->
37 <!-- AI Gateway Configuration (Per Store) -->
38 <!-- ========================================================= -->
39
40 <entity entity-name="ProductStoreAiConfig" package="moqui.mcp.agent">
41 <description>Configures AI Service Providers (OpenAI, VLLM, etc.) per Product Store.</description>
42
43 <field name="productStoreId" type="id" is-pk="true"/>
44 <field name="aiConfigId" type="id" is-pk="true"/>
45
46 <field name="serviceTypeEnumId" type="id"/>
47 <field name="description" type="text-medium"/>
48
49 <!-- Connection Details -->
50 <field name="endpointUrl" type="text-medium"/>
51 <field name="apiKey" type="text-medium" encrypt="true"/>
52 <field name="organizationId" type="text-short"/>
53
54 <!-- Model Parameters -->
55 <field name="modelName" type="text-short"/>
56 <field name="temperature" type="number-decimal"/>
57 <field name="maxTokens" type="number-integer"/>
58
59 <!-- System Prompt Template -->
60 <field name="systemMessageId" type="id">
61 <description>Template for the system prompt (instruction set)</description>
62 </field>
63
64 <relationship type="one" related="mantle.product.store.ProductStore">
65 <key-map field-name="productStoreId"/>
66 </relationship>
67 <relationship type="one" title="AiServiceType" related="moqui.basic.Enumeration">
68 <key-map field-name="serviceTypeEnumId" related="enumId"/>
69 </relationship>
70 <relationship type="one" title="PromptTemplate" related="moqui.service.message.SystemMessage">
71 <key-map field-name="systemMessageId"/>
72 </relationship>
73
74 <seed-data>
75 <!-- AI Service Types -->
76 <moqui.basic.EnumerationType description="AI Service Type" enumTypeId="AiServiceType"/>
77 <moqui.basic.Enumeration enumId="AistOpenAi" description="OpenAI" enumTypeId="AiServiceType"/>
78 <moqui.basic.Enumeration enumId="AistVllm" description="VLLM (OpenAI Compatible)" enumTypeId="AiServiceType"/>
79 <moqui.basic.Enumeration enumId="AistAnthropic" description="Anthropic" enumTypeId="AiServiceType"/>
80 <moqui.basic.Enumeration enumId="AistOllama" description="Ollama" enumTypeId="AiServiceType"/>
81
82 <!-- Agent Task Message Type -->
83 <moqui.basic.Enumeration enumId="SmtyAgentTask" description="Agent Task" enumTypeId="SystemMessageType"/>
84
85 <!-- AI Product Store Settings -->
86 <moqui.basic.Enumeration enumId="AiEndpointUrl" description="AI Endpoint URL" enumTypeId="ProductStoreSettingType"/>
87 <moqui.basic.Enumeration enumId="AiApiKey" description="AI API Key" enumTypeId="ProductStoreSettingType"/>
88 <moqui.basic.Enumeration enumId="AiModelName" description="AI Model Name" enumTypeId="ProductStoreSettingType"/>
89 <moqui.basic.Enumeration enumId="AiTemperature" description="AI Temperature" enumTypeId="ProductStoreSettingType"/>
90 </seed-data>
91 </entity>
92
93 </entities>
1 <?xml version="1.0" encoding="UTF-8"?>
2 <services xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3 xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/service-definition-3.xsd">
4
5 <!-- ========================================================= -->
6 <!-- Agent Tool Bridge (The Secure Gateway) -->
7 <!-- ========================================================= -->
8
9 <service verb="call" noun="McpToolWithDelegation">
10 <description>
11 Securely executes an MCP tool by impersonating the target user (runAsUserId).
12 The calling agent must have permission to use this service, but the
13 tool execution itself is subject to the target user's permissions.
14 </description>
15 <in-parameters>
16 <parameter name="toolName" required="true"/>
17 <parameter name="arguments" type="Map"/>
18 <parameter name="runAsUserId" required="true">
19 <description>The UserAccount ID to impersonate.</description>
20 </parameter>
21 </in-parameters>
22 <out-parameters>
23 <parameter name="result" type="Map"/>
24 </out-parameters>
25 <actions>
26 <script><![CDATA[
27 import org.moqui.mcp.adapter.McpToolAdapter
28 import org.moqui.context.ArtifactAuthorizationException
29
30 // 1. Capture current agent identity
31 String agentUsername = ec.user.username
32
33 try {
34 // 2. Switch identity to target user
35 // 'false' arg means don't trigger history/visit updates for this switch
36 boolean loggedIn = ec.user.internalLoginUser(runAsUserId, false)
37 if (!loggedIn) throw new Exception("Could not switch to user ${runAsUserId}")
38
39 ec.logger.info("Agent ${agentUsername} executing ${toolName} AS ${ec.user.username} (${runAsUserId})")
40
41 // 3. Execute Tool (Standard RBAC applies to this user)
42 McpToolAdapter adapter = new McpToolAdapter()
43
44 // The adapter MUST NOT disableAuthz internally for this to be secure
45 result = adapter.callTool(ec, toolName, arguments)
46
47 } finally {
48 // 4. Restore Agent Identity
49 if (agentUsername) {
50 ec.user.internalLoginUser(agentUsername, false)
51 }
52 }
53 ]]></script>
54 </actions>
55 </service>
56
57 <!-- ========================================================= -->
58 <!-- Agent Client (OpenAI-Compatible API Wrapper) -->
59 <!-- ========================================================= -->
60
61 <service verb="call" noun="OpenAiChatCompletion">
62 <description>Generic wrapper for OpenAI-compatible chat completions (VLLM, OpenAI, etc.)</description>
63 <in-parameters>
64 <parameter name="endpointUrl" required="true"/>
65 <parameter name="apiKey"/>
66 <parameter name="model" required="true"/>
67 <parameter name="messages" type="List" required="true"/>
68 <parameter name="tools" type="List"/>
69 <parameter name="temperature" type="BigDecimal" default="0.7"/>
70 <parameter name="maxTokens" type="Integer"/>
71 </in-parameters>
72 <out-parameters>
73 <parameter name="response" type="Map"/>
74 <parameter name="httpStatus" type="Integer"/>
75 <parameter name="error" type="String"/>
76 </out-parameters>
77 <actions>
78 <script><![CDATA[
79 import groovy.json.JsonBuilder
80 import groovy.json.JsonSlurper
81
82 // Construct payload
83 def payloadMap = [
84 model: model,
85 messages: messages,
86 temperature: temperature,
87 stream: false
88 ]
89
90 if (maxTokens) payloadMap.maxTokens = maxTokens
91 if (tools) payloadMap.tools = tools
92
93 String jsonPayload = new JsonBuilder(payloadMap).toString()
94
95 // Setup connection
96 URL url = new URL(endpointUrl + "/chat/completions")
97 HttpURLConnection conn = (HttpURLConnection) url.openConnection()
98 conn.setRequestMethod("POST")
99 conn.setRequestProperty("Content-Type", "application/json")
100 if (apiKey) conn.setRequestProperty("Authorization", "Bearer " + apiKey)
101 conn.setDoOutput(true)
102 conn.setConnectTimeout(10000) // 10s connect
103 conn.setReadTimeout(60000) // 60s read (LLMs are slow)
104
105 try {
106 conn.outputStream.write(jsonPayload.getBytes("UTF-8"))
107
108 httpStatus = conn.responseCode
109
110 InputStream is = (httpStatus >= 200 && httpStatus < 300) ? conn.inputStream : conn.errorStream
111 String responseText = is?.text
112
113 if (responseText) {
114 response = new JsonSlurper().parseText(responseText)
115 }
116
117 if (httpStatus >= 300) {
118 error = "HTTP ${httpStatus}: ${responseText}"
119 ec.logger.error("OpenAI Client Error: ${error}")
120 }
121
122 } catch (Exception e) {
123 error = e.message
124 httpStatus = 500
125 ec.logger.error("OpenAI Client Exception", e)
126 }
127 ]]></script>
128 </actions>
129 </service>
130
131 <!-- ========================================================= -->
132 <!-- Agent Runner (The Loop) -->
133 <!-- ========================================================= -->
134
135 <service verb="run" noun="AgentTask">
136 <description>
137 Processes a single Agent Task SystemMessage.
138 Handles the loop of: Prompt -> LLM -> Tool Call -> Tool Execution -> Prompt.
139 </description>
140 <in-parameters>
141 <parameter name="systemMessageId" required="true"/>
142 </in-parameters>
143 <actions>
144 <script><![CDATA[
145 import groovy.json.JsonOutput
146 import groovy.json.JsonSlurper
147
148 // 1. Load SystemMessage and Config
149 def taskMsg = ec.entity.find("moqui.service.message.SystemMessage")
150 .condition("systemMessageId", systemMessageId)
151 .one()
152
153 if (!taskMsg) return
154
155 // Get AI Config
156 def aiConfig = ec.entity.find("moqui.mcp.agent.ProductStoreAiConfig")
157 .condition("productStoreId", taskMsg.productStoreId)
158 .condition("aiConfigId", taskMsg.aiConfigId)
159 .one()
160
161 // Fallback to ProductStoreSetting if no specific AI Config found
162 def endpointUrl, apiKey, modelName, temperature
163
164 if (aiConfig) {
165 endpointUrl = aiConfig.endpointUrl
166 apiKey = aiConfig.apiKey
167 modelName = aiConfig.modelName
168 temperature = aiConfig.temperature
169 } else if (taskMsg.productStoreId) {
170 // Try ProductStoreSettings
171 def settings = ec.entity.find("mantle.product.store.ProductStoreSetting")
172 .condition("productStoreId", taskMsg.productStoreId)
173 .condition("settingTypeEnumId", ["AiEndpointUrl", "AiApiKey", "AiModelName", "AiTemperature"])
174 .list()
175
176 endpointUrl = settings.find { it.settingTypeEnumId == "AiEndpointUrl" }?.settingValue
177 apiKey = settings.find { it.settingTypeEnumId == "AiApiKey" }?.settingValue
178 modelName = settings.find { it.settingTypeEnumId == "AiModelName" }?.settingValue
179 temperature = settings.find { it.settingTypeEnumId == "AiTemperature" }?.settingValue?.toBigDecimal()
180 }
181
182 if (!endpointUrl || !modelName) {
183 ec.logger.error("No AI Configuration (Entity or Settings) found for task ${systemMessageId}")
184 taskMsg.statusId = "SmsError"
185 taskMsg.messageText = "Missing AI Configuration (Endpoint or Model)"
186 taskMsg.update()
187 return
188 }
189
190 // Default temperature if missing
191 if (temperature == null) temperature = 0.7
192
193 // 2. Prepare Tools (Convert MCP tools to OpenAI format)
194 def mcpToolAdapter = new org.moqui.mcp.adapter.McpToolAdapter()
195 def moquiTools = mcpToolAdapter.listTools()
196
197 // Filter out dangerous tools if needed? For now, we rely on RBAC delegation.
198
199 def openAiTools = moquiTools.collect { tool ->
200 [
201 type: "function",
202 function: [
203 name: tool.name,
204 description: tool.description,
205 // Helper to build schema (simplified for now, ideally strictly typed)
206 parameters: [
207 type: "object",
208 properties: [
209 path: [type: "string", description: "Screen path or resource URI"],
210 action: [type: "string", description: "Action to perform (create, update, etc)"],
211 parameters: [type: "object", description: "Key-value pairs for the action"]
212 ]
213 ]
214 ]
215 ]
216 }
217
218 // 3. Build Conversation History
219 // TODO: Load history if this is a continuation. For now, simple start.
220 def messages = [
221 [role: "system", content: "You are a helpful Moqui ERP assistant. You act as user ${taskMsg.requestedByUserId}."],
222 [role: "user", content: taskMsg.messageText]
223 ]
224
225 // 4. The Loop (Max 5 turns for safety)
226 int maxTurns = 5
227 int currentTurn = 0
228 boolean taskComplete = false
229
230 while (currentTurn < maxTurns && !taskComplete) {
231 currentTurn++
232
233 // Call LLM
234 def llmResult = ec.service.sync().name("AgentServices.call#OpenAiChatCompletion").parameters([
235 endpointUrl: aiConfig.endpointUrl,
236 apiKey: aiConfig.apiKey, // Decrypt if needed
237 model: aiConfig.modelName,
238 messages: messages,
239 tools: openAiTools,
240 temperature: aiConfig.temperature
241 ]).call()
242
243 if (llmResult.error) {
244 taskMsg.statusId = "SmsError"
245 taskMsg.messageText += "\nError: ${llmResult.error}"
246 taskMsg.update()
247 return
248 }
249
250 def responseMsg = llmResult.response.choices[0].message
251 messages.add(responseMsg) // Add assistant response to history
252
253 // Check for Tool Calls
254 if (responseMsg.tool_calls) {
255 ec.logger.info("Agent requesting ${responseMsg.tool_calls.size()} tools")
256
257 responseMsg.tool_calls.each { toolCall ->
258 def functionName = toolCall.function.name
259 def functionArgs = new JsonSlurper().parseText(toolCall.function.arguments)
260 def toolCallId = toolCall.id
261
262 // EXECUTE TOOL via Secure Bridge
263 def executionResult = [:]
264 try {
265 def runResult = ec.service.sync().name("AgentServices.call#McpToolWithDelegation").parameters([
266 toolName: functionName,
267 arguments: functionArgs,
268 runAsUserId: taskMsg.effectiveUserId // DELEGATION!
269 ]).call()
270
271 executionResult = runResult.result
272 } catch (Exception e) {
273 executionResult = [error: e.message]
274 }
275
276 // Add result to history
277 messages.add([
278 role: "tool",
279 tool_call_id: toolCallId,
280 content: JsonOutput.toJson(executionResult)
281 ])
282 }
283 // Loop continues to let LLM see results
284 } else {
285 // No tool calls = Final Response
286 taskComplete = true
287 taskMsg.statusId = "SmsProcessed"
288 taskMsg.messageText += "\n\n=== RESPONSE ===\n${responseMsg.content}"
289 taskMsg.update()
290 }
291 }
292 ]]></script>
293 </actions>
294 </service>
295
296 <!-- ========================================================= -->
297 <!-- Task Scheduler (Polls Queue) -->
298 <!-- ========================================================= -->
299
300 <service verb="poll" noun="AgentQueue">
301 <description>Scheduled service to pick up pending tasks.</description>
302 <actions>
303 <script><![CDATA[
304 import org.moqui.entity.EntityCondition
305
306 // Find pending tasks
307 def pendingTasks = ec.entity.find("moqui.service.message.SystemMessage")
308 .condition("statusId", "SmsReceived") // Or generic 'Pending'
309 .condition("systemMessageTypeId", "SmtyAgentTask")
310 .limit(5) // Batch size
311 .disableAuthz() // System service needs to see all tasks
312 .list()
313
314 pendingTasks.each { task ->
315 // Mark as In Progress
316 task.statusId = "SmsConsumed" // Or 'In Progress'
317 task.update()
318
319 // Run Async
320 ec.service.async().name("AgentServices.run#AgentTask")
321 .parameters([systemMessageId: task.systemMessageId])
322 .call()
323 }
324 ]]></script>
325 </actions>
326 </service>
327
328 </services>