AgentServices.xml 15.4 KB
<?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>