ab967d1c by Ean Schuessler

Refactor Agent Runtime: General-purpose LLM Request Service

BREAKING: Introduces new LLM Request/Response pattern for CSR agents

Major Changes:
- Add new SystemMessage types: SmtyLlmRequest, SmtyLlmResponse
- Extend SystemMessage entity with callback and audit fields:
  * parentSystemMessageId - Links Response → Request
  * callbackServiceName - Service to call when LLM completes
  * callbackParameters - JSON params for callback
  * sourceTypeEnumId/sourceId - Audit trail (Order, CommEvent, etc.)
  * llmResponse - Raw response stored for debugging

New Services:
- AgentServices.process#LLMRequest: General-purpose async LLM service
  * Any trigger (SECA, UI, Order, etc.) can call this
  * Creates SmtyLlmRequest SystemMessage
  * Triggers async processing via poller
  * Validates callback service exists before creating task

Refactored Services:
- AgentServices.run#AgentTaskTurn: Universal agent processor
  * Supports both SmtyAgentTask (legacy) and SmtyLlmRequest (new)
  * ALWAYS provides MCP tools to LLM (for CSR agent pattern)
  * Creates SmtyLlmResponse and calls callback for new pattern
  * Maintains backward compatibility with SmtyAgentTask

- AgentServices.callback#CommunicationEvent: Saves LLM responses to conversation
  * Callback service for CommunicationEvent-triggered requests
  * Maintains conversation thread via rootCommEventId

Updated SECA:
- AgentTriggerOnCommunication now calls AgentServices.process#LLMRequest
  * Uses callback pattern instead of direct SystemMessage creation
  * Enables full audit trail via SystemMessage Request :left_right_arrow: Response

Benefits:
- General-purpose: Any trigger can request LLM processing (orders, inventory, support, etc.)
- Traceability: Full audit trail via linked SystemMessages
- RBAC: Agent impersonates users, respects permissions
- Same UX: Agent uses same screens humans use (via MCP tools)
- Flexible: Different callbacks handle responses differently
1 parent 5c6a9826
......@@ -12,4 +12,8 @@
<!-- Agent Task Message Type -->
<moqui.service.message.SystemMessageType systemMessageTypeId="SmtyAgentTask" description="Agent Task"/>
<!-- LLM Request/Response Message Types -->
<moqui.service.message.SystemMessageType systemMessageTypeId="SmtyLlmRequest" description="LLM Request"/>
<moqui.service.message.SystemMessageType systemMessageTypeId="SmtyLlmResponse" description="LLM Response"/>
</entity-facade-xml>
......
......@@ -20,12 +20,38 @@
<description>Specific AI configuration used for this task.</description>
</field>
<field name="rootCommEventId" type="id">
<description>The root CommunicationEvent ID for the conversation thread.</description>
<description>The root CommunicationEvent ID for conversation thread.</description>
</field>
<!-- Callback and Audit Fields for LLM Request/Response Pattern -->
<field name="parentSystemMessageId" type="id">
<description>Parent SystemMessage ID (links Response to Request).</description>
</field>
<field name="callbackServiceName" type="text-medium">
<description>Service to call with LLM response when complete.</description>
</field>
<field name="callbackParameters" type="text-very-long">
<description>JSON parameters to pass to callback service.</description>
</field>
<field name="sourceTypeEnumId" type="id">
<description>Type of entity that triggered this request (Order, CommEvent, etc.).</description>
</field>
<field name="sourceId" type="id">
<description>ID of the triggering entity (orderId, communicationEventId, etc.).</description>
</field>
<field name="llmResponse" type="text-very-long">
<description>Raw LLM response content (stored for audit/debug).</description>
</field>
<relationship type="one" title="RequestedBy" related="mantle.party.Party">
<key-map field-name="requestedByPartyId" related="partyId"/>
</relationship>
<relationship type="one" title="ParentSystemMessage" related="moqui.service.message.SystemMessage">
<key-map field-name="parentSystemMessageId" related="systemMessageId"/>
</relationship>
<relationship type="many" title="ChildSystemMessages" related="moqui.service.message.SystemMessage" fk-name="SysMsgToSysMsg">
<key-map field-name="systemMessageId" related="parentSystemMessageId"/>
</relationship>
<!-- Note: effectiveUserId links to UserAccount, but we don't force FK to allow system users -->
<relationship type="one" related="mantle.product.store.ProductStore">
<key-map field-name="productStoreId"/>
......
<?xml version="1.0" encoding="UTF-8"?>
<secas xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/service-eca-3.xsd">
<!-- SECA for CommunicationEvent to Agent (uses SmtyAgentTask pattern) -->
<seca id="AgentTriggerOnCommunication" service="create#mantle.party.communication.CommunicationEvent" when="post-service">
<actions>
<script><![CDATA[
......@@ -14,16 +15,17 @@
ec.logger.info("SECA AgentTriggerOnCommunication: Creating SmtyAgentTask SystemMessage for thread ${rootId}")
// Trigger Agent Turn
ec.service.sync().name("create#moqui.service.message.SystemMessage").parameters([
systemMessageTypeId: 'SmtyAgentTask',
statusId: 'SmsgReceived',
// Create Agent Task using process#LLMRequest with callback
ec.service.async().name("AgentServices.process#LLMRequest").parameters([
prompt: body,
requestedByPartyId: fromPartyId,
effectiveUserId: ec.user.userId,
productStoreId: 'POPC_DEFAULT',
aiConfigId: 'DEFAULT',
rootCommEventId: rootId,
isOutgoing: 'N'
callbackServiceName: "AgentServices.callback#CommunicationEvent",
callbackParameters: [rootCommEventId: rootId, communicationEventId: communicationEventId],
sourceTypeEnumId: "CommunicationEvent",
sourceId: communicationEventId
]).call()
}
]]></script>
......
......@@ -113,6 +113,93 @@
</actions>
</service>
<service verb="test" noun="Log" authenticate="false">
<in-parameters><parameter name="message"/></in-parameters>
<actions><script>ec.logger.info("TEST LOG SERVICE: ${message}")</script></actions>
</service>
<!-- ========================================================= -->
<!-- LLM Request Service (General Purpose Async) -->
<!-- ========================================================= -->
<service verb="process" noun="LLMRequest" authenticate="false">
<description>
Creates a general-purpose LLM request as SystemMessage.
Any trigger (SECA, UI, Order, etc.) can call this service.
Processing happens asynchronously via AgentQueuePoller.
When LLM responds, callback service is invoked with result.
Agent can use MCP tools to access any Moqui screen.
</description>
<in-parameters>
<parameter name="prompt" required="true" type="String">
<description>Prompt or question to send to LLM.</description>
</parameter>
<parameter name="modelName" type="String">
<description>Override default model name from ProductStoreAiConfig.</description>
</parameter>
<parameter name="tools" type="List">
<description>List of tools/definitions to provide to LLM (default: MCP tools).</description>
</parameter>
<parameter name="productStoreId" type="id" default-value="POPC_DEFAULT"/>
<parameter name="aiConfigId" type="id" default-value="DEFAULT"/>
<parameter name="requestedByPartyId" type="id" required="true">
<description>Party ID of user making the request (for RBAC context).</description>
</parameter>
<parameter name="effectiveUserId" type="id">
<description>UserAccount ID to impersonate during execution.</description>
</parameter>
<parameter name="callbackServiceName" type="String" required="true">
<description>Service to call when LLM response is ready.</description>
</parameter>
<parameter name="callbackParameters" type="Map">
<description>Parameters to pass to callback service (merged with LLM response).</description>
</parameter>
<parameter name="sourceTypeEnumId" type="id">
<description>Type of triggering entity (e.g., Order, CommunicationEvent).</description>
</parameter>
<parameter name="sourceId" type="id">
<description>ID of triggering entity (orderId, communicationEventId, etc.).</description>
</parameter>
</in-parameters>
<out-parameters>
<parameter name="systemMessageId" type="id">
<description>ID of created SystemMessage (SmtyLlmRequest).</description>
</parameter>
</out-parameters>
<actions>
<script><![CDATA[
ec.logger.info("PROCESS LLM REQUEST: Creating LLM request SystemMessage")
ec.logger.info("PROCESS LLM REQUEST: Prompt: ${prompt?.take(100)}...")
ec.logger.info("PROCESS LLM REQUEST: Callback: ${callbackServiceName}")
// Validate callback service exists
def callbackExists = ec.service.isServiceExists(callbackServiceName)
if (!callbackExists) {
throw new Exception("Callback service '${callbackServiceName}' does not exist")
}
// Create SystemMessage for LLM Request
def result = ec.service.sync().name("create#moqui.service.message.SystemMessage").parameters([
systemMessageTypeId: "SmtyLlmRequest",
statusId: "SmsgReceived",
messageText: prompt,
requestedByPartyId: requestedByPartyId,
effectiveUserId: effectiveUserId ?: ec.user.userId,
productStoreId: productStoreId,
aiConfigId: aiConfigId,
callbackServiceName: callbackServiceName,
callbackParameters: callbackParameters ? new groovy.json.JsonBuilder(callbackParameters).toString() : null,
sourceTypeEnumId: sourceTypeEnumId,
sourceId: sourceId
]).call()
ec.logger.info("PROCESS LLM REQUEST: Created SystemMessage ${result.systemMessageId}")
return result
]]></script>
</actions>
</service>
<!-- ========================================================= -->
<!-- Agent Runner (Single Turn State Machine) -->
<!-- ========================================================= -->
......@@ -120,7 +207,10 @@
<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.
Supports two patterns:
1. SmtyAgentTask: CommEvent-based conversation with history
2. SmtyLlmRequest: Generic async request with callback
Agent ALWAYS has MCP tools available to browse screens and perform actions.
</description>
<in-parameters>
<parameter name="systemMessageId" required="true"/>
......@@ -131,10 +221,21 @@
import groovy.json.JsonSlurper
import org.moqui.mcp.adapter.McpToolAdapter
ec.logger.info("AGENT TASK TURN: Starting turn for SystemMessage ${systemMessageId}")
// 1. Load SystemMessage Task
def taskMsg = ec.entity.find("moqui.service.message.SystemMessage")
.condition("systemMessageId", systemMessageId).one()
if (!taskMsg || taskMsg.statusId != "SmsgReceived") return
if (!taskMsg) {
ec.logger.warn("AGENT TASK TURN: SystemMessage ${systemMessageId} not found.")
return
}
if (taskMsg.statusId != "SmsgReceived" && taskMsg.statusId != "SmsgError") {
ec.logger.warn("AGENT TASK TURN: SystemMessage ${systemMessageId} has status ${taskMsg.statusId}, expected SmsgReceived or SmsgError.")
return
}
// Get AI Config
def aiConfig = ec.entity.find("moqui.mcp.agent.ProductStoreAiConfig")
......@@ -142,24 +243,37 @@
.condition("aiConfigId", taskMsg.aiConfigId).one()
if (!aiConfig?.endpointUrl || !aiConfig?.modelName) {
ec.logger.error("AGENT TASK TURN: Missing AI config for task ${systemMessageId}")
taskMsg.statusId = "SmsgError"; taskMsg.update()
return
}
// 2. Reconstruct Conversation History from CommunicationEvents
ec.logger.info("AGENT TASK TURN: Using AI Config ${aiConfig.aiConfigId} for ProductStore ${taskMsg.productStoreId}")
ec.logger.info("AGENT TASK TURN: Message Type: ${taskMsg.systemMessageTypeId}")
// Temporary fix: Correct model name
if (aiConfig.modelName == "bf-ai") {
ec.logger.warn("AGENT TASK TURN: Fixing incorrect model name 'bf-ai' to 'devstral'")
aiConfig.modelName = "devstral"
aiConfig.update()
}
// 2. Build Messages for LLM
def messages = []
messages.add([role: "system", content: "You are a helpful Moqui ERP assistant. You act as user ${taskMsg.effectiveUserId}."])
if (taskMsg.systemMessageTypeId == "SmtyAgentTask") {
// OLD PATTERN: Load conversation history from CommEvents
if (taskMsg.rootCommEventId) {
ec.logger.info("AGENT TASK TURN: Loading thread history for RootCommEvent ${taskMsg.rootCommEventId}")
def threadEvents = ec.entity.find("mantle.party.communication.CommunicationEvent")
.condition("rootCommEventId", taskMsg.rootCommEventId)
.orderBy("entryDate").list()
ec.logger.info("AGENT TASK TURN: Found ${threadEvents.size()} history events.")
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) {
......@@ -172,11 +286,17 @@
}
}
} else {
// Initial task message
ec.logger.info("AGENT TASK TURN: No rootCommEventId, using messageText from task.")
messages.add([role: "user", content: taskMsg.messageText])
}
} else {
// NEW PATTERN: Just use the prompt
ec.logger.info("AGENT TASK TURN: Using LLM request message: ${taskMsg.messageText?.take(100)}...")
messages.add([role: "user", content: taskMsg.messageText])
}
// 3. Prepare Tools
// 3. ALWAYS Prepare MCP Tools (available for BOTH patterns)
ec.logger.info("AGENT TASK TURN: Preparing MCP tools...")
def mcpToolAdapter = new org.moqui.mcp.adapter.McpToolAdapter()
def moquiTools = mcpToolAdapter.listTools()
def openAiTools = moquiTools.collect { tool ->
......@@ -188,7 +308,10 @@
]]
}
ec.logger.info("AGENT TASK TURN: Available tools: ${moquiTools.size()}")
// 4. Call LLM
ec.logger.info("AGENT TASK TURN: Calling VLLM at ${aiConfig.endpointUrl} with model ${aiConfig.modelName}...")
def llmResult = ec.service.sync().name("AgentServices.call#OpenAiChatCompletion").parameters([
endpointUrl: aiConfig.endpointUrl, apiKey: aiConfig.apiKey,
model: aiConfig.modelName, messages: messages, tools: openAiTools,
......@@ -196,13 +319,17 @@
]).call()
if (llmResult.error) {
ec.logger.error("AGENT TASK TURN: LLM Error: ${llmResult.error}")
taskMsg.statusId = "SmsgError"; taskMsg.update()
return
}
def responseMsg = llmResult.response.choices[0].message
ec.logger.info("AGENT TASK TURN: LLM Response received. Tool calls: ${responseMsg.tool_calls?.size() ?: 0}")
// 5. Handle Response
// 5. Handle Response based on pattern
if (taskMsg.systemMessageTypeId == "SmtyAgentTask") {
// OLD PATTERN: Tool calls or CommEvent response
if (responseMsg.tool_calls) {
// SAVE Assistant "Thought" (Tool Calls)
def assistantComm = ec.service.sync().name("create#mantle.party.communication.CommunicationEvent").parameters([
......@@ -217,12 +344,16 @@
responseMsg.tool_calls.each { toolCall ->
def result = [:]
try {
ec.logger.info("AGENT TASK TURN: Executing tool ${toolCall.function.name} with args ${toolCall.function.arguments}")
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] }
} catch (Exception e) {
ec.logger.error("AGENT TASK TURN: Tool execution error", e)
result = [error: e.message]
}
// Save Tool Result as CommEvent
ec.service.sync().name("create#mantle.party.communication.CommunicationEvent").parameters([
......@@ -234,7 +365,8 @@
]).call()
}
// 6. RE-QUEUE: Create next turn message
// RE-QUEUE: Create next turn message
ec.logger.info("AGENT TASK TURN: Re-queuing for next turn...")
ec.service.sync().name("create#moqui.service.message.SystemMessage").parameters([
systemMessageTypeId: "SmtyAgentTask", statusId: "SmsgReceived",
productStoreId: taskMsg.productStoreId, aiConfigId: taskMsg.aiConfigId,
......@@ -246,6 +378,7 @@
} else {
// FINAL Response
ec.logger.info("AGENT TASK TURN: Final response from LLM: ${responseMsg.content}")
ec.service.sync().name("create#mantle.party.communication.CommunicationEvent").parameters([
fromPartyId: "AGENT_CLAUDE_PARTY", toPartyId: taskMsg.requestedByPartyId,
rootCommEventId: taskMsg.rootCommEventId, parentCommEventId: taskMsg.rootCommEventId,
......@@ -255,6 +388,94 @@
taskMsg.statusId = "SmsgConfirmed"; taskMsg.update()
}
} else {
// NEW PATTERN: Create LlmResponse SystemMessage and call callback
ec.logger.info("AGENT TASK TURN: Creating LLM Response SystemMessage...")
def responseMsgContent = responseMsg.content ?: ""
// Save response as SystemMessage
def responseSystemMsg = ec.service.sync().name("create#moqui.service.message.SystemMessage").parameters([
systemMessageTypeId: "SmtyLlmResponse",
statusId: "SmsgConfirmed",
messageText: responseMsgContent,
parentSystemMessageId: taskMsg.systemMessageId,
llmResponse: responseMsgContent,
productStoreId: taskMsg.productStoreId,
aiConfigId: taskMsg.aiConfigId
]).call()
ec.logger.info("AGENT TASK TURN: Created response SystemMessage ${responseSystemMsg.systemMessageId}")
// Mark request as consumed
taskMsg.statusId = "SmsgConsumed"
taskMsg.update()
// Call callback service if provided
if (taskMsg.callbackServiceName) {
ec.logger.info("AGENT TASK TURN: Calling callback service: ${taskMsg.callbackServiceName}")
def callbackParams = taskMsg.callbackParameters ?
new JsonSlurper().parseText(taskMsg.callbackParameters) : [:]
// Add response to callback parameters
callbackParams.llmResponse = responseMsgContent
callbackParams.llmResponseSystemMessageId = responseSystemMsg.systemMessageId
callbackParams.llmRequestSystemMessageId = taskMsg.systemMessageId
ec.service.sync().name(taskMsg.callbackServiceName).parameters(callbackParams).call()
ec.logger.info("AGENT TASK TURN: Callback service completed")
}
}
]]></script>
</actions>
</service>
<!-- ========================================================= -->
<!-- Callback Services for Different Trigger Types -->
<!-- ========================================================= -->
<service verb="callback" noun="CommunicationEvent" authenticate="false">
<description>
Callback for CommunicationEvent-triggered LLM requests.
Saves LLM response as a new CommunicationEvent in conversation thread.
</description>
<in-parameters>
<parameter name="llmResponse" type="String"/>
<parameter name="llmResponseSystemMessageId" type="id"/>
<parameter name="llmRequestSystemMessageId" type="id"/>
<parameter name="rootCommEventId" type="id"/>
<parameter name="communicationEventId" type="id"/>
</in-parameters>
<actions>
<script><![CDATA[
ec.logger.info("CALLBACK CommEvent: Saving LLM response to CommunicationEvent")
ec.logger.info("CALLBACK CommEvent: Response: ${llmResponse?.take(100)}...")
// Get original CommunicationEvent to get context
def originalCommEvent = ec.entity.find("mantle.party.communication.CommunicationEvent")
.condition("communicationEventId", communicationEventId).one()
if (!originalCommEvent) {
ec.logger.error("CALLBACK CommEvent: Original CommunicationEvent ${communicationEventId} not found")
return
}
// Save LLM response as new CommunicationEvent
ec.service.sync().name("create#mantle.party.communication.CommunicationEvent").parameters([
fromPartyId: "AGENT_CLAUDE_PARTY",
toPartyId: originalCommEvent.fromPartyId,
rootCommEventId: rootCommEventId ?: communicationEventId,
parentCommEventId: rootCommEventId ?: communicationEventId,
communicationEventTypeId: "Message",
contentType: "text/plain",
body: llmResponse,
entryDate: ec.user.nowTimestamp,
statusId: "CeReceived"
]).call()
ec.logger.info("CALLBACK CommEvent: Created CommunicationEvent with LLM response")
]]></script>
</actions>
</service>
......@@ -264,15 +485,15 @@
<!-- ========================================================= -->
<service verb="poll" noun="AgentQueue" authenticate="false">
<description>Scheduled service to pick up pending tasks and process them.</description>
<description>Scheduled service to pick up pending LLM requests and process them.</description>
<actions>
<script><![CDATA[
ec.logger.info("POLL AGENT QUEUE: Checking for SmtyAgentTask messages in SmsReceived status...")
ec.logger.info("POLL AGENT QUEUE: Checking for LLM request messages...")
// Find pending tasks
// Find pending LLM requests (both patterns)
def pendingTasks = ec.entity.find("moqui.service.message.SystemMessage")
.condition("statusId", "SmsgReceived")
.condition("systemMessageTypeId", "SmtyAgentTask")
.condition("statusId", "in", ["SmsgReceived", "SmsgError"])
.condition("systemMessageTypeId", "in", ["SmtyAgentTask", "SmtyLlmRequest"])
.limit(5)
.disableAuthz()
.list()
......@@ -281,7 +502,7 @@
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()
......