AgentServices.xml 15.3 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" authenticate="false">
        <description>
            Securely executes an MCP tool by impersonating target user (runAsUserId).
            The calling agent must have permission to use this service, but 
            tool execution itself is subject to 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" authenticate="false">
        <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
                
                // 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)
                            ])
                        }
                    } else {
                        // No tool calls = Final Response
                        taskComplete = true
                    }
                }
            ]]></script>
        </actions>
    </service>
    
    <!-- ========================================================= -->
    <!-- Task Scheduler (Polls Queue)                              -->
    <!-- ========================================================= -->
    
    <service verb="poll" noun="AgentQueue" authenticate="false">
        <description>Scheduled service to pick up pending tasks and process them.</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"
                    task.update()
                    
                    // Run Agent Task service (noStatusUpdate=false to prevent auto status change)
                    ec.service.sync().name("AgentServices.run#AgentTask")
                        .parameters([
                            systemMessageId: task.systemMessageId,
                            noStatusUpdate: false
                        ])
                        .call()
                }
            ]]></script>
        </actions>
    </service>
                        .parameters([systemMessageId: task.systemMessageId])
                        .call()
                }
            ]]></script>
        </actions>
    </service>

</services>