AgentServices.xml 14.7 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
                    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
                    McpToolAdapter adapter = new McpToolAdapter()
                    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" authenticate="false">
        <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
                
                def payloadMap = [
                    model: model,
                    messages: messages,
                    temperature: temperature,
                    stream: false
                ]
                
                if (maxTokens) payloadMap.max_tokens = maxTokens
                if (tools) payloadMap.tools = tools
                
                String jsonPayload = new JsonBuilder(payloadMap).toString()
                
                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)
                conn.setReadTimeout(60000)
                
                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}"
                } catch (Exception e) {
                    error = e.message
                    httpStatus = 500
                    ec.logger.error("OpenAI Client Exception", e)
                }
            ]]></script>
        </actions>
    </service>
    
    <!-- ========================================================= -->
    <!-- Agent Runner (Single Turn State Machine)                  -->
    <!-- ========================================================= -->
    
    <service verb="run" noun="AgentTaskTurn" authenticate="false">
        <description>
            Processes ONE turn of an Agent Task. 
            Loads thread history, calls LLM, executes ONE set of tools, saves state, and re-queues if needed.
        </description>
        <in-parameters>
            <parameter name="systemMessageId" required="true"/>
        </in-parameters>
        <actions>
            <script><![CDATA[
                import groovy.json.JsonOutput
                import groovy.json.JsonSlurper
                import org.moqui.mcp.adapter.McpToolAdapter

                // 1. Load SystemMessage Task
                def taskMsg = ec.entity.find("moqui.service.message.SystemMessage")
                    .condition("systemMessageId", systemMessageId).one()
                if (!taskMsg || taskMsg.statusId != "SmsgReceived") return

                // Get AI Config
                def aiConfig = ec.entity.find("moqui.mcp.agent.ProductStoreAiConfig")
                    .condition("productStoreId", taskMsg.productStoreId)
                    .condition("aiConfigId", taskMsg.aiConfigId).one()
                
                if (!aiConfig?.endpointUrl || !aiConfig?.modelName) {
                    taskMsg.statusId = "SmsgError"; taskMsg.update()
                    return
                }

                // 2. Reconstruct Conversation History from CommunicationEvents
                def messages = []
                messages.add([role: "system", content: "You are a helpful Moqui ERP assistant. You act as user ${taskMsg.effectiveUserId}."])
                
                if (taskMsg.rootCommEventId) {
                    def threadEvents = ec.entity.find("mantle.party.communication.CommunicationEvent")
                        .condition("rootCommEventId", taskMsg.rootCommEventId)
                        .orderBy("entryDate").list()
                    
                    threadEvents.each { ev ->
                        // Distinguish roles based on fromPartyId
                        String role = (ev.fromPartyId == "AGENT_CLAUDE_PARTY") ? "assistant" : "user"
                        
                        // Check if it's a tool result (stored in contentType application/json)
                        if (ev.contentType == "application/json") {
                            def json = new JsonSlurper().parseText(ev.body)
                            if (json.tool_call_id) {
                                messages.add([role: "tool", tool_call_id: json.tool_call_id, content: json.content])
                            } else if (json.tool_calls) {
                                messages.add([role: "assistant", tool_calls: json.tool_calls])
                            }
                        } else {
                            messages.add([role: role, content: ev.body])
                        }
                    }
                } else {
                    // Initial task message
                    messages.add([role: "user", content: taskMsg.messageText])
                }

                // 3. Prepare Tools
                def mcpToolAdapter = new org.moqui.mcp.adapter.McpToolAdapter()
                def moquiTools = mcpToolAdapter.listTools()
                def openAiTools = moquiTools.collect { tool ->
                    [type: "function", function: [
                        name: tool.name, description: tool.description,
                        parameters: [type: "object", properties: [
                            path: [type: "string"], action: [type: "string"], parameters: [type: "object"]
                        ]]
                    ]]
                }

                // 4. Call LLM
                def llmResult = ec.service.sync().name("AgentServices.call#OpenAiChatCompletion").parameters([
                    endpointUrl: aiConfig.endpointUrl, apiKey: aiConfig.apiKey,
                    model: aiConfig.modelName, messages: messages, tools: openAiTools,
                    temperature: aiConfig.temperature
                ]).call()

                if (llmResult.error) {
                    taskMsg.statusId = "SmsgError"; taskMsg.update()
                    return
                }

                def responseMsg = llmResult.response.choices[0].message
                
                // 5. Handle Response
                if (responseMsg.tool_calls) {
                    // SAVE Assistant "Thought" (Tool Calls)
                    def assistantComm = ec.service.sync().name("create#mantle.party.communication.CommunicationEvent").parameters([
                        fromPartyId: "AGENT_CLAUDE_PARTY", toPartyId: taskMsg.requestedByPartyId,
                        rootCommEventId: taskMsg.rootCommEventId, parentCommEventId: taskMsg.rootCommEventId,
                        communicationEventTypeId: "Message", contentType: "application/json",
                        body: JsonOutput.toJson([tool_calls: responseMsg.tool_calls]),
                        entryDate: ec.user.nowTimestamp, statusId: "CeReceived"
                    ]).call()

                    // EXECUTE Tools and Save Results
                    responseMsg.tool_calls.each { toolCall ->
                        def result = [:]
                        try {
                            def runResult = ec.service.sync().name("AgentServices.call#McpToolWithDelegation").parameters([
                                toolName: toolCall.function.name, arguments: new JsonSlurper().parseText(toolCall.function.arguments),
                                runAsUserId: taskMsg.effectiveUserId
                            ]).call()
                            result = runResult.result
                        } catch (Exception e) { result = [error: e.message] }

                        // Save Tool Result as CommEvent
                        ec.service.sync().name("create#mantle.party.communication.CommunicationEvent").parameters([
                            fromPartyId: taskMsg.requestedByPartyId, toPartyId: "AGENT_CLAUDE_PARTY",
                            rootCommEventId: taskMsg.rootCommEventId, parentCommEventId: assistantComm.communicationEventId,
                            communicationEventTypeId: "Message", contentType: "application/json",
                            body: JsonOutput.toJson([tool_call_id: toolCall.id, content: JsonOutput.toJson(result)]),
                            entryDate: ec.user.nowTimestamp, statusId: "CeReceived"
                        ]).call()
                    }

                    // 6. RE-QUEUE: Create next turn message
                    ec.service.sync().name("create#moqui.service.message.SystemMessage").parameters([
                        systemMessageTypeId: "SmtyAgentTask", statusId: "SmsgReceived",
                        productStoreId: taskMsg.productStoreId, aiConfigId: taskMsg.aiConfigId,
                        requestedByPartyId: taskMsg.requestedByPartyId, effectiveUserId: taskMsg.effectiveUserId,
                        rootCommEventId: taskMsg.rootCommEventId, isOutgoing: "N"
                    ]).call()

                    taskMsg.statusId = "SmsgConsumed"; taskMsg.update()

                } else {
                    // FINAL Response
                    ec.service.sync().name("create#mantle.party.communication.CommunicationEvent").parameters([
                        fromPartyId: "AGENT_CLAUDE_PARTY", toPartyId: taskMsg.requestedByPartyId,
                        rootCommEventId: taskMsg.rootCommEventId, parentCommEventId: taskMsg.rootCommEventId,
                        communicationEventTypeId: "Message", contentType: "text/plain",
                        body: responseMsg.content, entryDate: ec.user.nowTimestamp, statusId: "CeReceived"
                    ]).call()

                    taskMsg.statusId = "SmsgConfirmed"; taskMsg.update()
                }
            ]]></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[
                ec.logger.info("POLL AGENT QUEUE: Checking for SmtyAgentTask messages in SmsReceived status...")
                
                // Find pending tasks
                def pendingTasks = ec.entity.find("moqui.service.message.SystemMessage")
                    .condition("statusId", "SmsgReceived")
                    .condition("systemMessageTypeId", "SmtyAgentTask")
                    .limit(5)
                    .disableAuthz()
                    .list()
                
                ec.logger.info("POLL AGENT QUEUE: Found ${pendingTasks.size()} tasks to process.")
                
                pendingTasks.each { task ->
                    ec.logger.info("POLL AGENT QUEUE: Processing task ${task.systemMessageId}")
                    // Run Agent Task Turn
                    ec.service.sync().name("AgentServices.run#AgentTaskTurn")
                        .parameters([systemMessageId: task.systemMessageId])
                        .call()
                }
            ]]></script>
        </actions>
    </service>

</services>