Implement Reliable Agent Architecture using CommunicationEvent thread logic
- Update AgentServices.xml: Single-turn state machine (process one turn, re-queue if tool called) - Add Agent.secas.xml: Trigger Agent Turn on new CommunicationEvent to Agent Party - Update AgentEntities.xml: Add rootCommEventId to SystemMessage for thread tracking - Update AgentData.xml: Define Agent Party, Role, and default VLLM config
Showing
4 changed files
with
179 additions
and
176 deletions
| ... | @@ -2,20 +2,18 @@ | ... | @@ -2,20 +2,18 @@ |
| 2 | <entity-facade-xml xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | 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"> | 3 | xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/entity-facade-3.xsd" type="seed"> |
| 4 | 4 | ||
| 5 | <!-- ========================================================= --> | 5 | <moqui.security.UserGroup userGroupId="AgentUsers" description="AI Agent Users"/> |
| 6 | <!-- Agent User Account (for authentication) --> | ||
| 7 | <!-- ========================================================= --> | ||
| 8 | 6 | ||
| 9 | <moqui.security.UserAccount | 7 | <!-- Agent Party --> |
| 10 | userId="AGENT_CLAUDE" | 8 | <mantle.party.Party partyId="AGENT_CLAUDE_PARTY" partyTypeEnumId="PtyPerson"/> |
| 11 | username="agent-claude" | 9 | <mantle.party.Person partyId="AGENT_CLAUDE_PARTY" firstName="Claude" lastName="Agent"/> |
| 12 | currentPassword="16ac58bbfa332c1c55bd98b53e60720bfa90d394" | 10 | <mantle.party.PartyRole partyId="AGENT_CLAUDE_PARTY" roleTypeId="Agent"/> |
| 13 | passwordHashType="SHA"/> | ||
| 14 | 11 | ||
| 15 | <moqui.security.UserGroup userGroupId="AgentUsers" description="AI Agent Users"/> | 12 | <moqui.security.UserAccount userId="AGENT_CLAUDE" username="agent-claude" partyId="AGENT_CLAUDE_PARTY" |
| 13 | currentPassword="16ac58bbfa332c1c55bd98b53e60720bfa90d394" passwordHashType="SHA"/> | ||
| 16 | <moqui.security.UserGroupMember userGroupId="AgentUsers" userId="AGENT_CLAUDE" fromDate="2026-02-04 00:00:00.000"/> | 14 | <moqui.security.UserGroupMember userGroupId="AgentUsers" userId="AGENT_CLAUDE" fromDate="2026-02-04 00:00:00.000"/> |
| 17 | 15 | ||
| 18 | <!-- Agent users have permission to execute the delegation service --> | 16 | <!-- Agent users have permission to execute delegation service --> |
| 19 | <moqui.security.ArtifactGroup artifactGroupId="AgentDelegationServices" description="Agent Tool Delegation Services"/> | 17 | <moqui.security.ArtifactGroup artifactGroupId="AgentDelegationServices" description="Agent Tool Delegation Services"/> |
| 20 | <moqui.security.ArtifactGroupMember artifactGroupId="AgentDelegationServices" artifactName="AgentServices.call#McpToolWithDelegation" artifactTypeEnumId="AT_SERVICE"/> | 18 | <moqui.security.ArtifactGroupMember artifactGroupId="AgentDelegationServices" artifactName="AgentServices.call#McpToolWithDelegation" artifactTypeEnumId="AT_SERVICE"/> |
| 21 | <moqui.security.ArtifactAuthz userGroupId="AgentUsers" artifactGroupId="AgentDelegationServices" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/> | 19 | <moqui.security.ArtifactAuthz userGroupId="AgentUsers" artifactGroupId="AgentDelegationServices" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/> |
| ... | @@ -27,7 +25,13 @@ | ... | @@ -27,7 +25,13 @@ |
| 27 | <!-- Agent Task Message Type --> | 25 | <!-- Agent Task Message Type --> |
| 28 | <moqui.service.message.SystemMessageType systemMessageTypeId="SmtyAgentTask" description="Agent Task" | 26 | <moqui.service.message.SystemMessageType systemMessageTypeId="SmtyAgentTask" description="Agent Task" |
| 29 | contentType="application/json" | 27 | contentType="application/json" |
| 30 | consumeServiceName="AgentServices.poll#AgentQueue" | 28 | consumeServiceName="AgentServices.poll#AgentQueue"/> |
| 31 | receiveServiceName=""/> | 29 | |
| 30 | <!-- Default AI Config (Brainfood VLLM) --> | ||
| 31 | <moqui.mcp.agent.ProductStoreAiConfig | ||
| 32 | productStoreId="POPC_DEFAULT" aiConfigId="DEFAULT" | ||
| 33 | serviceTypeEnumId="AistVllm" description="Brainfood VLLM" | ||
| 34 | endpointUrl="http://crunchy.private.brainfood.com:11434/v1" apiKey="brainfood" | ||
| 35 | modelName="bf-ai" temperature="0.7" maxTokens="4096"/> | ||
| 32 | 36 | ||
| 33 | </entity-facade-xml> | 37 | </entity-facade-xml> | ... | ... |
| ... | @@ -19,6 +19,9 @@ | ... | @@ -19,6 +19,9 @@ |
| 19 | <field name="aiConfigId" type="id"> | 19 | <field name="aiConfigId" type="id"> |
| 20 | <description>Specific AI configuration used for this task.</description> | 20 | <description>Specific AI configuration used for this task.</description> |
| 21 | </field> | 21 | </field> |
| 22 | <field name="rootCommEventId" type="id"> | ||
| 23 | <description>The root CommunicationEvent ID for the conversation thread.</description> | ||
| 24 | </field> | ||
| 22 | 25 | ||
| 23 | <relationship type="one" title="RequestedBy" related="mantle.party.Party"> | 26 | <relationship type="one" title="RequestedBy" related="mantle.party.Party"> |
| 24 | <key-map field-name="requestedByPartyId" related="partyId"/> | 27 | <key-map field-name="requestedByPartyId" related="partyId"/> | ... | ... |
service/Agent.secas.xml
0 → 100644
| 1 | <?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | <secas xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/service-eca-3.xsd"> | ||
| 3 | <seca id="AgentTriggerOnCommunication" service="create#mantle.party.communication.CommunicationEvent" when="post-service"> | ||
| 4 | <condition> | ||
| 5 | <expression>toPartyId == 'AGENT_CLAUDE_PARTY'</expression> | ||
| 6 | </condition> | ||
| 7 | <actions> | ||
| 8 | <!-- Ensure rootCommEventId is set (thread tracking) --> | ||
| 9 | <script><![CDATA[ | ||
| 10 | def rootId = rootCommEventId ?: communicationEventId | ||
| 11 | if (!rootCommEventId) { | ||
| 12 | ec.service.sync().name("update#mantle.party.communication.CommunicationEvent") | ||
| 13 | .parameters([communicationEventId: communicationEventId, rootCommEventId: rootId]) | ||
| 14 | .call() | ||
| 15 | } | ||
| 16 | |||
| 17 | // Trigger Agent Turn | ||
| 18 | ec.service.sync().name("create#moqui.service.message.SystemMessage").parameters([ | ||
| 19 | systemMessageTypeId: 'SmtyAgentTask', | ||
| 20 | statusId: 'SmsgReceived', | ||
| 21 | requestedByPartyId: fromPartyId, | ||
| 22 | effectiveUserId: ec.user.userId, // Use the actual human user ID for RBAC | ||
| 23 | productStoreId: 'POPC_DEFAULT', | ||
| 24 | aiConfigId: 'DEFAULT', | ||
| 25 | rootCommEventId: rootId, | ||
| 26 | isOutgoing: 'N' | ||
| 27 | ]).call() | ||
| 28 | ]]></script> | ||
| 29 | </actions> | ||
| 30 | </seca> | ||
| 31 | </secas> |
| ... | @@ -32,16 +32,13 @@ | ... | @@ -32,16 +32,13 @@ |
| 32 | 32 | ||
| 33 | try { | 33 | try { |
| 34 | // 2. Switch identity to target user | 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) | 35 | boolean loggedIn = ec.user.internalLoginUser(runAsUserId, false) |
| 37 | if (!loggedIn) throw new Exception("Could not switch to user ${runAsUserId}") | 36 | if (!loggedIn) throw new Exception("Could not switch to user ${runAsUserId}") |
| 38 | 37 | ||
| 39 | ec.logger.info("Agent ${agentUsername} executing ${toolName} AS ${ec.user.username} (${runAsUserId})") | 38 | ec.logger.info("Agent ${agentUsername} executing ${toolName} AS ${ec.user.username} (${runAsUserId})") |
| 40 | 39 | ||
| 41 | // 3. Execute Tool (Standard RBAC applies to this user) | 40 | // 3. Execute Tool |
| 42 | McpToolAdapter adapter = new McpToolAdapter() | 41 | McpToolAdapter adapter = new McpToolAdapter() |
| 43 | |||
| 44 | // The adapter MUST NOT disableAuthz internally for this to be secure | ||
| 45 | result = adapter.callTool(ec, toolName, arguments) | 42 | result = adapter.callTool(ec, toolName, arguments) |
| 46 | 43 | ||
| 47 | } finally { | 44 | } finally { |
| ... | @@ -79,7 +76,6 @@ | ... | @@ -79,7 +76,6 @@ |
| 79 | import groovy.json.JsonBuilder | 76 | import groovy.json.JsonBuilder |
| 80 | import groovy.json.JsonSlurper | 77 | import groovy.json.JsonSlurper |
| 81 | 78 | ||
| 82 | // Construct payload | ||
| 83 | def payloadMap = [ | 79 | def payloadMap = [ |
| 84 | model: model, | 80 | model: model, |
| 85 | messages: messages, | 81 | messages: messages, |
| ... | @@ -87,38 +83,27 @@ | ... | @@ -87,38 +83,27 @@ |
| 87 | stream: false | 83 | stream: false |
| 88 | ] | 84 | ] |
| 89 | 85 | ||
| 90 | if (maxTokens) payloadMap.maxTokens = maxTokens | 86 | if (maxTokens) payloadMap.max_tokens = maxTokens |
| 91 | if (tools) payloadMap.tools = tools | 87 | if (tools) payloadMap.tools = tools |
| 92 | 88 | ||
| 93 | String jsonPayload = new JsonBuilder(payloadMap).toString() | 89 | String jsonPayload = new JsonBuilder(payloadMap).toString() |
| 94 | 90 | ||
| 95 | // Setup connection | ||
| 96 | URL url = new URL(endpointUrl + "/chat/completions") | 91 | URL url = new URL(endpointUrl + "/chat/completions") |
| 97 | HttpURLConnection conn = (HttpURLConnection) url.openConnection() | 92 | HttpURLConnection conn = (HttpURLConnection) url.openConnection() |
| 98 | conn.setRequestMethod("POST") | 93 | conn.setRequestMethod("POST") |
| 99 | conn.setRequestProperty("Content-Type", "application/json") | 94 | conn.setRequestProperty("Content-Type", "application/json") |
| 100 | if (apiKey) conn.setRequestProperty("Authorization", "Bearer " + apiKey) | 95 | if (apiKey) conn.setRequestProperty("Authorization", "Bearer " + apiKey) |
| 101 | conn.setDoOutput(true) | 96 | conn.setDoOutput(true) |
| 102 | conn.setConnectTimeout(10000) // 10s connect | 97 | conn.setConnectTimeout(10000) |
| 103 | conn.setReadTimeout(60000) // 60s read (LLMs are slow) | 98 | conn.setReadTimeout(60000) |
| 104 | 99 | ||
| 105 | try { | 100 | try { |
| 106 | conn.outputStream.write(jsonPayload.getBytes("UTF-8")) | 101 | conn.outputStream.write(jsonPayload.getBytes("UTF-8")) |
| 107 | |||
| 108 | httpStatus = conn.responseCode | 102 | httpStatus = conn.responseCode |
| 109 | |||
| 110 | InputStream is = (httpStatus >= 200 && httpStatus < 300) ? conn.inputStream : conn.errorStream | 103 | InputStream is = (httpStatus >= 200 && httpStatus < 300) ? conn.inputStream : conn.errorStream |
| 111 | String responseText = is?.text | 104 | String responseText = is?.text |
| 112 | 105 | if (responseText) response = new JsonSlurper().parseText(responseText) | |
| 113 | if (responseText) { | 106 | if (httpStatus >= 300) error = "HTTP ${httpStatus}: ${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) { | 107 | } catch (Exception e) { |
| 123 | error = e.message | 108 | error = e.message |
| 124 | httpStatus = 500 | 109 | httpStatus = 500 |
| ... | @@ -129,13 +114,13 @@ | ... | @@ -129,13 +114,13 @@ |
| 129 | </service> | 114 | </service> |
| 130 | 115 | ||
| 131 | <!-- ========================================================= --> | 116 | <!-- ========================================================= --> |
| 132 | <!-- Agent Runner (The Loop) --> | 117 | <!-- Agent Runner (Single Turn State Machine) --> |
| 133 | <!-- ========================================================= --> | 118 | <!-- ========================================================= --> |
| 134 | 119 | ||
| 135 | <service verb="run" noun="AgentTask" authenticate="false"> | 120 | <service verb="run" noun="AgentTaskTurn" authenticate="false"> |
| 136 | <description> | 121 | <description> |
| 137 | Processes a single Agent Task SystemMessage. | 122 | Processes ONE turn of an Agent Task. |
| 138 | Handles the loop of: Prompt -> LLM -> Tool Call -> Tool Execution -> Prompt. | 123 | Loads thread history, calls LLM, executes ONE set of tools, saves state, and re-queues if needed. |
| 139 | </description> | 124 | </description> |
| 140 | <in-parameters> | 125 | <in-parameters> |
| 141 | <parameter name="systemMessageId" required="true"/> | 126 | <parameter name="systemMessageId" required="true"/> |
| ... | @@ -144,147 +129,136 @@ | ... | @@ -144,147 +129,136 @@ |
| 144 | <script><![CDATA[ | 129 | <script><![CDATA[ |
| 145 | import groovy.json.JsonOutput | 130 | import groovy.json.JsonOutput |
| 146 | import groovy.json.JsonSlurper | 131 | import groovy.json.JsonSlurper |
| 147 | 132 | import org.moqui.mcp.adapter.McpToolAdapter | |
| 148 | // 1. Load SystemMessage and Config | 133 | |
| 134 | // 1. Load SystemMessage Task | ||
| 149 | def taskMsg = ec.entity.find("moqui.service.message.SystemMessage") | 135 | def taskMsg = ec.entity.find("moqui.service.message.SystemMessage") |
| 150 | .condition("systemMessageId", systemMessageId) | 136 | .condition("systemMessageId", systemMessageId).one() |
| 151 | .one() | 137 | if (!taskMsg || taskMsg.statusId != "SmsgReceived") return |
| 152 | 138 | ||
| 153 | if (!taskMsg) return | ||
| 154 | |||
| 155 | // Get AI Config | 139 | // Get AI Config |
| 156 | def aiConfig = ec.entity.find("moqui.mcp.agent.ProductStoreAiConfig") | 140 | def aiConfig = ec.entity.find("moqui.mcp.agent.ProductStoreAiConfig") |
| 157 | .condition("productStoreId", taskMsg.productStoreId) | 141 | .condition("productStoreId", taskMsg.productStoreId) |
| 158 | .condition("aiConfigId", taskMsg.aiConfigId) | 142 | .condition("aiConfigId", taskMsg.aiConfigId).one() |
| 159 | .one() | ||
| 160 | 143 | ||
| 161 | // Fallback to ProductStoreSetting if no specific AI Config found | 144 | if (!aiConfig?.endpointUrl || !aiConfig?.modelName) { |
| 162 | def endpointUrl, apiKey, modelName, temperature | 145 | taskMsg.statusId = "SmsgError"; taskMsg.update() |
| 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 | 146 | return |
| 188 | } | 147 | } |
| 148 | |||
| 149 | // 2. Reconstruct Conversation History from CommunicationEvents | ||
| 150 | def messages = [] | ||
| 151 | messages.add([role: "system", content: "You are a helpful Moqui ERP assistant. You act as user ${taskMsg.effectiveUserId}."]) | ||
| 189 | 152 | ||
| 190 | // Default temperature if missing | 153 | if (taskMsg.rootCommEventId) { |
| 191 | if (temperature == null) temperature = 0.7 | 154 | def threadEvents = ec.entity.find("mantle.party.communication.CommunicationEvent") |
| 192 | 155 | .condition("rootCommEventId", taskMsg.rootCommEventId) | |
| 193 | // Filter out dangerous tools if needed? For now, we rely on RBAC delegation. | 156 | .orderBy("entryDate").list() |
| 194 | |||
| 195 | def openAiTools = moquiTools.collect { tool -> | ||
| 196 | [ | ||
| 197 | type: "function", | ||
| 198 | function: [ | ||
| 199 | name: tool.name, | ||
| 200 | description: tool.description, | ||
| 201 | // Helper to build schema (simplified for now, ideally strictly typed) | ||
| 202 | parameters: [ | ||
| 203 | type: "object", | ||
| 204 | properties: [ | ||
| 205 | path: [type: "string", description: "Screen path or resource URI"], | ||
| 206 | action: [type: "string", description: "Action to perform (create, update, etc)"], | ||
| 207 | parameters: [type: "object", description: "Key-value pairs for the action"] | ||
| 208 | ] | ||
| 209 | ] | ||
| 210 | ] | ||
| 211 | ] | ||
| 212 | } | ||
| 213 | |||
| 214 | // 3. Build Conversation History | ||
| 215 | // TODO: Load history if this is a continuation. For now, simple start. | ||
| 216 | def messages = [ | ||
| 217 | [role: "system", content: "You are a helpful Moqui ERP assistant. You act as user ${taskMsg.requestedByUserId}."], | ||
| 218 | [role: "user", content: taskMsg.messageText] | ||
| 219 | ] | ||
| 220 | |||
| 221 | // 4. The Loop (Max 5 turns for safety) | ||
| 222 | int maxTurns = 5 | ||
| 223 | int currentTurn = 0 | ||
| 224 | boolean taskComplete = false | ||
| 225 | |||
| 226 | while (currentTurn < maxTurns && !taskComplete) { | ||
| 227 | currentTurn++ | ||
| 228 | |||
| 229 | // Call LLM | ||
| 230 | def llmResult = ec.service.sync().name("AgentServices.call#OpenAiChatCompletion").parameters([ | ||
| 231 | endpointUrl: aiConfig.endpointUrl, | ||
| 232 | apiKey: aiConfig.apiKey, // Decrypt if needed | ||
| 233 | model: aiConfig.modelName, | ||
| 234 | messages: messages, | ||
| 235 | tools: openAiTools, | ||
| 236 | temperature: aiConfig.temperature | ||
| 237 | ]).call() | ||
| 238 | |||
| 239 | if (llmResult.error) { | ||
| 240 | taskMsg.statusId = "SmsError" | ||
| 241 | taskMsg.messageText += "\nError: ${llmResult.error}" | ||
| 242 | taskMsg.update() | ||
| 243 | return | ||
| 244 | } | ||
| 245 | |||
| 246 | def responseMsg = llmResult.response.choices[0].message | ||
| 247 | messages.add(responseMsg) // Add assistant response to history | ||
| 248 | 157 | ||
| 249 | // Check for Tool Calls | 158 | threadEvents.each { ev -> |
| 250 | if (responseMsg.tool_calls) { | 159 | // Distinguish roles based on fromPartyId |
| 251 | ec.logger.info("Agent requesting ${responseMsg.tool_calls.size()} tools") | 160 | String role = (ev.fromPartyId == "AGENT_CLAUDE_PARTY") ? "assistant" : "user" |
| 252 | 161 | ||
| 253 | responseMsg.tool_calls.each { toolCall -> | 162 | // Check if it's a tool result (stored in contentType application/json) |
| 254 | def functionName = toolCall.function.name | 163 | if (ev.contentType == "application/json") { |
| 255 | def functionArgs = new JsonSlurper().parseText(toolCall.function.arguments) | 164 | def json = new JsonSlurper().parseText(ev.body) |
| 256 | def toolCallId = toolCall.id | 165 | if (json.tool_call_id) { |
| 257 | 166 | messages.add([role: "tool", tool_call_id: json.tool_call_id, content: json.content]) | |
| 258 | // EXECUTE TOOL via Secure Bridge | 167 | } else if (json.tool_calls) { |
| 259 | def executionResult = [:] | 168 | messages.add([role: "assistant", tool_calls: json.tool_calls]) |
| 260 | try { | ||
| 261 | def runResult = ec.service.sync().name("AgentServices.call#McpToolWithDelegation").parameters([ | ||
| 262 | toolName: functionName, | ||
| 263 | arguments: functionArgs, | ||
| 264 | runAsUserId: taskMsg.effectiveUserId // DELEGATION! | ||
| 265 | ]).call() | ||
| 266 | |||
| 267 | executionResult = runResult.result | ||
| 268 | } catch (Exception e) { | ||
| 269 | executionResult = [error: e.message] | ||
| 270 | } | 169 | } |
| 271 | 170 | } else { | |
| 272 | // Add result to history | 171 | messages.add([role: role, content: ev.body]) |
| 273 | messages.add([ | ||
| 274 | role: "tool", | ||
| 275 | tool_call_id: toolCallId, | ||
| 276 | content: JsonOutput.toJson(executionResult) | ||
| 277 | ]) | ||
| 278 | } | 172 | } |
| 279 | } else { | ||
| 280 | // No tool calls = Final Response | ||
| 281 | taskComplete = true | ||
| 282 | } | 173 | } |
| 174 | } else { | ||
| 175 | // Initial task message | ||
| 176 | messages.add([role: "user", content: taskMsg.messageText]) | ||
| 177 | } | ||
| 178 | |||
| 179 | // 3. Prepare Tools | ||
| 180 | def mcpToolAdapter = new org.moqui.mcp.adapter.McpToolAdapter() | ||
| 181 | def moquiTools = mcpToolAdapter.listTools() | ||
| 182 | def openAiTools = moquiTools.collect { tool -> | ||
| 183 | [type: "function", function: [ | ||
| 184 | name: tool.name, description: tool.description, | ||
| 185 | parameters: [type: "object", properties: [ | ||
| 186 | path: [type: "string"], action: [type: "string"], parameters: [type: "object"] | ||
| 187 | ]] | ||
| 188 | ]] | ||
| 189 | } | ||
| 190 | |||
| 191 | // 4. Call LLM | ||
| 192 | def llmResult = ec.service.sync().name("AgentServices.call#OpenAiChatCompletion").parameters([ | ||
| 193 | endpointUrl: aiConfig.endpointUrl, apiKey: aiConfig.apiKey, | ||
| 194 | model: aiConfig.modelName, messages: messages, tools: openAiTools, | ||
| 195 | temperature: aiConfig.temperature | ||
| 196 | ]).call() | ||
| 197 | |||
| 198 | if (llmResult.error) { | ||
| 199 | taskMsg.statusId = "SmsgError"; taskMsg.update() | ||
| 200 | return | ||
| 201 | } | ||
| 202 | |||
| 203 | def responseMsg = llmResult.response.choices[0].message | ||
| 204 | |||
| 205 | // 5. Handle Response | ||
| 206 | if (responseMsg.tool_calls) { | ||
| 207 | // SAVE Assistant "Thought" (Tool Calls) | ||
| 208 | def assistantComm = ec.service.sync().name("create#mantle.party.communication.CommunicationEvent").parameters([ | ||
| 209 | fromPartyId: "AGENT_CLAUDE_PARTY", toPartyId: taskMsg.requestedByPartyId, | ||
| 210 | rootCommEventId: taskMsg.rootCommEventId, parentCommEventId: taskMsg.rootCommEventId, | ||
| 211 | communicationEventTypeId: "Message", contentType: "application/json", | ||
| 212 | body: JsonOutput.toJson([tool_calls: responseMsg.tool_calls]), | ||
| 213 | entryDate: ec.user.nowTimestamp, statusId: "CeReceived" | ||
| 214 | ]).call() | ||
| 215 | |||
| 216 | // EXECUTE Tools and Save Results | ||
| 217 | responseMsg.tool_calls.each { toolCall -> | ||
| 218 | def result = [:] | ||
| 219 | try { | ||
| 220 | def runResult = ec.service.sync().name("AgentServices.call#McpToolWithDelegation").parameters([ | ||
| 221 | toolName: toolCall.function.name, arguments: new JsonSlurper().parseText(toolCall.function.arguments), | ||
| 222 | runAsUserId: taskMsg.effectiveUserId | ||
| 223 | ]).call() | ||
| 224 | result = runResult.result | ||
| 225 | } catch (Exception e) { result = [error: e.message] } | ||
| 226 | |||
| 227 | // Save Tool Result as CommEvent | ||
| 228 | ec.service.sync().name("create#mantle.party.communication.CommunicationEvent").parameters([ | ||
| 229 | fromPartyId: taskMsg.requestedByPartyId, toPartyId: "AGENT_CLAUDE_PARTY", | ||
| 230 | rootCommEventId: taskMsg.rootCommEventId, parentCommEventId: assistantComm.communicationEventId, | ||
| 231 | communicationEventTypeId: "Message", contentType: "application/json", | ||
| 232 | body: JsonOutput.toJson([tool_call_id: toolCall.id, content: JsonOutput.toJson(result)]), | ||
| 233 | entryDate: ec.user.nowTimestamp, statusId: "CeReceived" | ||
| 234 | ]).call() | ||
| 235 | } | ||
| 236 | |||
| 237 | // 6. RE-QUEUE: Create next turn message | ||
| 238 | ec.service.sync().name("create#moqui.service.message.SystemMessage").parameters([ | ||
| 239 | systemMessageTypeId: "SmtyAgentTask", statusId: "SmsgReceived", | ||
| 240 | productStoreId: taskMsg.productStoreId, aiConfigId: taskMsg.aiConfigId, | ||
| 241 | requestedByPartyId: taskMsg.requestedByPartyId, effectiveUserId: taskMsg.effectiveUserId, | ||
| 242 | rootCommEventId: taskMsg.rootCommEventId, isOutgoing: "N" | ||
| 243 | ]).call() | ||
| 244 | |||
| 245 | taskMsg.statusId = "SmsgConsumed"; taskMsg.update() | ||
| 246 | |||
| 247 | } else { | ||
| 248 | // FINAL Response | ||
| 249 | ec.service.sync().name("create#mantle.party.communication.CommunicationEvent").parameters([ | ||
| 250 | fromPartyId: "AGENT_CLAUDE_PARTY", toPartyId: taskMsg.requestedByPartyId, | ||
| 251 | rootCommEventId: taskMsg.rootCommEventId, parentCommEventId: taskMsg.rootCommEventId, | ||
| 252 | communicationEventTypeId: "Message", contentType: "text/plain", | ||
| 253 | body: responseMsg.content, entryDate: ec.user.nowTimestamp, statusId: "CeReceived" | ||
| 254 | ]).call() | ||
| 255 | |||
| 256 | taskMsg.statusId = "SmsgConfirmed"; taskMsg.update() | ||
| 283 | } | 257 | } |
| 284 | ]]></script> | 258 | ]]></script> |
| 285 | </actions> | 259 | </actions> |
| 286 | </service> | 260 | </service> |
| 287 | 261 | ||
| 288 | <!-- ========================================================= --> | 262 | <!-- ========================================================= --> |
| 289 | <!-- Task Scheduler (Polls Queue) --> | 263 | <!-- Task Scheduler (Polls Queue) --> |
| 290 | <!-- ========================================================= --> | 264 | <!-- ========================================================= --> |
| ... | @@ -293,27 +267,18 @@ | ... | @@ -293,27 +267,18 @@ |
| 293 | <description>Scheduled service to pick up pending tasks and process them.</description> | 267 | <description>Scheduled service to pick up pending tasks and process them.</description> |
| 294 | <actions> | 268 | <actions> |
| 295 | <script><![CDATA[ | 269 | <script><![CDATA[ |
| 296 | import org.moqui.entity.EntityCondition | ||
| 297 | |||
| 298 | // Find pending tasks | 270 | // Find pending tasks |
| 299 | def pendingTasks = ec.entity.find("moqui.service.message.SystemMessage") | 271 | def pendingTasks = ec.entity.find("moqui.service.message.SystemMessage") |
| 300 | .condition("statusId", "SmsReceived") // Or generic 'Pending' | 272 | .condition("statusId", "SmsgReceived") |
| 301 | .condition("systemMessageTypeId", "SmtyAgentTask") | 273 | .condition("systemMessageTypeId", "SmtyAgentTask") |
| 302 | .limit(5) // Batch size | 274 | .limit(5) |
| 303 | .disableAuthz() // System service needs to see all tasks | 275 | .disableAuthz() |
| 304 | .list() | 276 | .list() |
| 305 | 277 | ||
| 306 | pendingTasks.each { task -> | 278 | pendingTasks.each { task -> |
| 307 | // Mark as In Progress | 279 | // Run Agent Task Turn |
| 308 | task.statusId = "SmsConsumed" | 280 | ec.service.sync().name("AgentServices.run#AgentTaskTurn") |
| 309 | task.update() | 281 | .parameters([systemMessageId: task.systemMessageId]) |
| 310 | |||
| 311 | // Run Agent Task service (noStatusUpdate=false to prevent auto status change) | ||
| 312 | ec.service.sync().name("AgentServices.run#AgentTask") | ||
| 313 | .parameters([ | ||
| 314 | systemMessageId: task.systemMessageId, | ||
| 315 | noStatusUpdate: false | ||
| 316 | ]) | ||
| 317 | .call() | 282 | .call() |
| 318 | } | 283 | } |
| 319 | ]]></script> | 284 | ]]></script> | ... | ... |
-
Please register or sign in to post a comment