Parcourir la source

#5406 AI Agents revamp: code refactoring

Aleksandr Trofimov il y a 1 mois
Parent
commit
dfe04f4aea

+ 19 - 414
inc/classes/BxDolAi.php

@@ -54,7 +54,7 @@ class BxDolAi extends BxDolFactory implements iBxDolSingleton
         if (isset($GLOBALS['bxDolClasses'][__CLASS__ . '_Agent_' . $iId]))
             return $GLOBALS['bxDolClasses'][__CLASS__ . '_Agent_' . $iId];
 
-        $a = BxDolAIQuery::getAgentObject($iId);
+        $a = BxDolAiQuery::getAgentObject($iId);
         if (!$a) {
             $s = "Agent with id {$iId} not found";
             bx_log('sys_agents', $s, BX_LOG_ERR);
@@ -77,7 +77,7 @@ class BxDolAi extends BxDolFactory implements iBxDolSingleton
             $o->toolMaxRuns($a['tools_max_run']);
 
         if ($a['vector_store_id']) {
-            $aVectorStore = BxDolAIQuery::getVectorStoreObject($a['vector_store_id']);
+            $aVectorStore = BxDolAiQuery::getVectorStoreObject($a['vector_store_id']);
             if ($aVectorStore && $aVectorStore['embedding_provider_id']) {
                 $oEmbedder = self::getAiEmbeddingsProviderInstance($aVectorStore['embedding_provider_id']);
                 $o->setEmbeddingsProvider($oEmbedder);
@@ -122,58 +122,12 @@ class BxDolAi extends BxDolFactory implements iBxDolSingleton
 
     public function callAgent($sType, $aAgent, $mixedParams = [])
     {
-        if ($mixedParams)
-            $sParams = is_string($mixedParams) ? $mixedParams : json_encode($mixedParams);
-        else
-            $sParams = 'START';
-
-        // update sample data
-        $a = ['webhook' => 'webhook_sample'];
-        if (isset($a[$sType]) && empty($aAgent[$a[$sType]])) {
-            $this->_oDb->updateAgentField($aAgent['id'], $a[$sType], $sParams);
-        }
-
-        // set additional params
-        $aParams = [];
-        if ('message' == $sType || 'form-input' == $sType || 'alert' == $sType) {
-            $aParams = ['chat_history_subindex' => (int)($mixedParams['sender_profile_id'] ?? 0)];
-        }
-
-        if ('message' == $sType && is_array($mixedParams) && isset($mixedParams['message_text'])) {
-            $mixedParams['message_text'] = $this->applyChatInputLimit((string)$mixedParams['message_text'], $aAgent);
-            $sParams = json_encode($mixedParams);
-        }
-        else if (is_string($mixedParams) && in_array($sType, ['manual', 'message'], true)) {
-            $sParams = $this->applyChatInputLimit($sParams, $aAgent);
-        }
-
-        if (in_array($sType, ['manual', 'message'], true) && $this->isChatTurnLimitReached($aAgent, $this->getChatUserTurnCount($aAgent['id'], $aParams))) {
-            $this->emitConversationClosed('limit', '', $aAgent, $aParams);
-            return $this->getChatLimitMessage($aAgent);
-        }
-
-        if (in_array($sType, ['manual', 'message'], true) && $this->isChatSessionRateLimited($aAgent, $aParams))
-            throw new Exception($this->getChatSessionRateLimitError());
+        return BxDolAiTrigger::getInstance($sType)->call($aAgent, $mixedParams);
+    }
 
+    public function setChatContext($aAgent, $aParams)
+    {
         $this->_aChatContext = ['agent' => $aAgent, 'params' => $aParams];
-
-        // call agent
-        $mixed = '';
-        try {                        
-            $o = self::getAgentInstance($aAgent['id'], $aParams);
-            if (!$o)
-                return false;
-
-            $oMessage = $o->chat(new NeuronAI\Chat\Messages\UserMessage($sParams))->getMessage();
-
-            $mixed = $oMessage->getContent();
-
-        } catch (Exception $exception) {            
-            bx_log('sys_agents', "Exception in '{$aAgent['name']}' agent: " . $exception->getMessage() . " INPUT:" . $sParams, BX_LOG_ERR);
-            $mixed = _t('_sys_agents_exception');
-        }
-
-        return $mixed;
     }
 
     /**
@@ -367,39 +321,7 @@ class BxDolAi extends BxDolFactory implements iBxDolSingleton
 
     public function extractChatPromptFromRequest($aData)
     {
-        if (!is_array($aData))
-            return '';
-
-        if (!empty($aData['prompt']) && is_string($aData['prompt']))
-            return trim($aData['prompt']);
-
-        if (empty($aData['messages']) || !is_array($aData['messages']))
-            return '';
-
-        for ($i = count($aData['messages']) - 1; $i >= 0; $i--) {
-            $aMessage = $aData['messages'][$i];
-            if (!is_array($aMessage))
-                continue;
-
-            if (($aMessage['role'] ?? '') !== 'user')
-                continue;
-
-            if (isset($aMessage['content']) && is_string($aMessage['content']))
-                return trim($aMessage['content']);
-
-            if (!empty($aMessage['parts']) && is_array($aMessage['parts'])) {
-                $aText = [];
-                foreach ($aMessage['parts'] as $aPart) {
-                    if (is_array($aPart) && ($aPart['type'] ?? '') === 'text' && isset($aPart['content']))
-                        $aText[] = $aPart['content'];
-                }
-                $s = trim(implode("\n", $aText));
-                if ($s !== '')
-                    return $s;
-            }
-        }
-
-        return '';
+        return BxDolAiTrigger::getInstance('chat')->extractPromptFromRequest($aData);
     }
 
     /**
@@ -422,7 +344,7 @@ class BxDolAi extends BxDolFactory implements iBxDolSingleton
                 foreach (array_unique(array_map('intval', $aIds)) as $iStoredAgentId) {
                     if (!$iStoredAgentId || $sSessionId === '')
                         continue;
-                    $aAgent = BxDolAIQuery::getAgentObject($iStoredAgentId);
+                    $aAgent = BxDolAiQuery::getAgentObject($iStoredAgentId);
                     if ($aAgent)
                         $this->_oDb->adoptGuestChatHistory($aAgent, $sSessionId, $iProfileId);
                 }
@@ -846,7 +768,7 @@ class BxDolAi extends BxDolFactory implements iBxDolSingleton
         if (!isset($aParams['chat_history_subindex']))
             $aParams = array_merge($this->resolveChatHistoryParams($iAgentId), $aParams);
 
-        $aAgent = BxDolAIQuery::getAgentObject((int)$iAgentId);
+        $aAgent = BxDolAiQuery::getAgentObject((int)$iAgentId);
         if (!$aAgent || empty($aAgent['chat_history_context']))
             return [];
 
@@ -978,7 +900,7 @@ class BxDolAi extends BxDolFactory implements iBxDolSingleton
     /**
      * @return array{content: string, actions: array}|null
      */
-    protected function parseAssistantChatPayload($sText)
+    public function parseAssistantChatPayload($sText)
     {
         $s = trim((string)$sText);
         if ($s === '')
@@ -1062,338 +984,19 @@ class BxDolAi extends BxDolFactory implements iBxDolSingleton
         return in_array($sHost, $aAllowed, true);
     }
 
-    protected function sseChatEvent($aPayload)
-    {
-        return 'data: ' . json_encode($aPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n\n";
-    }
-
-    protected function parseSseChatEvent($sEvent)
-    {
-        $s = trim((string)$sEvent);
-        if (!preg_match('/^data:\s*(.+)$/s', $s, $aM))
-            return null;
-
-        $a = json_decode($aM[1], true);
-        return is_array($a) ? $a : null;
-    }
-
-    protected function resetChatActionsSseState()
-    {
-        return [
-            'mode' => 'undecided',
-            'buf' => '',
-            'messageId' => '',
-            'held' => [],
-        ];
-    }
-
-    protected function emitHeldChatActionsSse(&$aState, $fEmit)
-    {
-        foreach ($aState['held'] as $sHeld)
-            $fEmit($sHeld);
-        $aState['held'] = [];
-    }
-
-    protected function emitParsedChatActionsSse(&$aState, $fEmit, $aParsed)
-    {
-        $sId = $aState['messageId'] !== '' ? $aState['messageId'] : ('msg_' . uniqid());
-        $fEmit($this->sseChatEvent([
-            'type' => 'TEXT_MESSAGE_START',
-            'messageId' => $sId,
-            'role' => 'assistant',
-        ]));
-        if ($aParsed['content'] !== '') {
-            $fEmit($this->sseChatEvent([
-                'type' => 'TEXT_MESSAGE_CONTENT',
-                'messageId' => $sId,
-                'delta' => $aParsed['content'],
-            ]));
-        }
-        $fEmit($this->sseChatEvent([
-            'type' => 'TEXT_MESSAGE_END',
-            'messageId' => $sId,
-        ]));
-        if (!empty($aParsed['actions'])) {
-            $fEmit($this->sseChatEvent([
-                'type' => 'CUSTOM',
-                'name' => 'chat_actions',
-                'messageId' => $sId,
-                'value' => $aParsed['actions'],
-            ]));
-        }
-        $aState = $this->resetChatActionsSseState();
-    }
-
-    protected function processChatActionsSseEvent($sEvent, &$aState, $fEmit)
-    {
-        $aPayload = $this->parseSseChatEvent($sEvent);
-        $sType = is_array($aPayload) ? (string)($aPayload['type'] ?? '') : '';
-
-        if ($sType === 'TEXT_MESSAGE_START') {
-            if ($aState['held'] || $aState['buf'] !== '')
-                $this->flushChatActionsSseState($aState, $fEmit);
-            $aState['messageId'] = (string)($aPayload['messageId'] ?? '');
-            $aState['held'][] = $sEvent;
-            return;
-        }
-
-        if ($sType === 'TEXT_MESSAGE_CONTENT') {
-            $sDelta = (string)($aPayload['delta'] ?? '');
-            $aState['buf'] .= $sDelta;
-            if ($aState['mode'] === 'text') {
-                $fEmit($sEvent);
-                return;
-            }
-            if ($aState['mode'] === 'undecided') {
-                $mixed = $this->chatActionsStreamLooksLikeJson($aState['buf']);
-                if ($mixed === false) {
-                    $aState['mode'] = 'text';
-                    $this->emitHeldChatActionsSse($aState, $fEmit);
-                    $fEmit($sEvent);
-                    return;
-                }
-                if ($mixed === true)
-                    $aState['mode'] = 'json';
-            }
-            $aState['held'][] = $sEvent;
-            return;
-        }
-
-        if ($sType === 'TEXT_MESSAGE_END') {
-            $aParsed = $this->parseAssistantChatPayload($aState['buf']);
-            if ($aParsed && ($aState['mode'] === 'json' || $aState['mode'] === 'undecided')) {
-                $this->emitParsedChatActionsSse($aState, $fEmit, $aParsed);
-                return;
-            }
-            $this->emitHeldChatActionsSse($aState, $fEmit);
-            $fEmit($sEvent);
-            $aState = $this->resetChatActionsSseState();
-            return;
-        }
-
-        if ($sType === 'RUN_ERROR' || $sType === 'RUN_FINISHED') {
-            $this->flushChatActionsSseState($aState, $fEmit);
-            $fEmit($sEvent);
-            return;
-        }
-
-        if ($aState['mode'] === 'json' || $aState['held'])
-            $this->flushChatActionsSseState($aState, $fEmit);
-
-        $fEmit($sEvent);
-    }
-
-    protected function chatActionsStreamLooksLikeJson($sBuf)
-    {
-        $s = ltrim((string)$sBuf);
-        if ($s === '')
-            return null;
-        $sFirst = $s[0];
-        if ($sFirst === '{' || $sFirst === '`')
-            return true;
-        return false;
-    }
-
-    protected function flushChatActionsSseState(&$aState, $fEmit)
-    {
-        if ($aState['buf'] === '' && !$aState['held'])
-            return;
-
-        $aParsed = $this->parseAssistantChatPayload($aState['buf']);
-        if ($aParsed && $aState['mode'] !== 'text') {
-            $this->emitParsedChatActionsSse($aState, $fEmit, $aParsed);
-            return;
-        }
-
-        $this->emitHeldChatActionsSse($aState, $fEmit);
-        $aState = $this->resetChatActionsSseState();
-    }
-
-    protected function persistAssistantChatActions($oAgent)
-    {
-        if (!is_object($oAgent) || !method_exists($oAgent, 'getChatHistory'))
-            return;
-
-        $oHistory = $oAgent->getChatHistory();
-        if (!($oHistory instanceof BxDolAiChatHistory))
-            return;
-
-        $aMessages = $oHistory->getMessages();
-        for ($i = count($aMessages) - 1; $i >= 0; $i--) {
-            $oMessage = $aMessages[$i];
-            if (
-                $oMessage instanceof NeuronAI\Chat\Messages\ToolCallMessage
-                || $oMessage instanceof NeuronAI\Chat\Messages\ToolResultMessage
-            ) {
-                continue;
-        }
-
-            $sRole = $oMessage->getRole();
-            if ($sRole === 'model')
-                $sRole = 'assistant';
-            if ($sRole !== 'assistant')
-                return;
-
-            $aParsed = $this->parseAssistantChatPayload((string)$oMessage->getContent());
-            if (!$aParsed)
-                return;
-
-            $oMessage->setContents($aParsed['content']);
-            $oMessage->addMetadata('actions', $aParsed['actions']);
-            $oHistory->persistMessages();
-            return;
-        }
-    }
-
     public function streamAgentChat($iAgentId, $sPrompt, $aParams = [], $sThreadId = null)
     {
-        if (!isset($aParams['chat_history_subindex']))
-            $aParams = array_merge($this->resolveChatHistoryParams($iAgentId), $aParams);
-
-        // Staging header.inc.php dumps HTML on fatals and warns on null error_get_last().
-        // Any HTML after SSE is parsed by TanStack as "unterminated trailing data".
-        @ini_set('display_errors', '0');
-        @ini_set('zlib.output_compression', '0');
-        @ini_set('implicit_flush', '1');
-        if (function_exists('apache_setenv'))
-            @apache_setenv('no-gzip', '1');
-        while (ob_get_level())
-            ob_end_clean();
-
-        // UNA BxDolDb::pdoExceptionHandler dumps HTML 503 on uncaught PDOException.
-        restore_exception_handler();
-
-        $oAdapter = new NeuronAI\Chat\Messages\Stream\Adapters\AGUIAdapter($sThreadId);
-
-        http_response_code(200);
-        foreach ($oAdapter->getHeaders() as $sName => $sValue)
-            header($sName . ': ' . $sValue);
-
-        $fEmit = function ($sEvent) {
-            echo $sEvent;
-            if (ob_get_level())
-                ob_flush();
-            flush();
-        };
-
-        $aAgent = BxDolAIQuery::getAgentObject((int)$iAgentId);
-        if ($aAgent)
-            $this->_aChatContext = ['agent' => $aAgent, 'params' => $aParams];
-
-        if ($aAgent && $this->isChatSessionRateLimited($aAgent, $aParams)) {
-            $this->emitChatErrorSse($oAdapter, $fEmit, $this->getChatSessionRateLimitError());
-            error_clear_last();
-            @ini_set('display_errors', '0');
-            exit;
-        }
-
-        $iRequestTurns = (int)($aParams['request_user_turns'] ?? 0);
-        if ($aAgent && $this->isChatTurnLimitReached($aAgent, $this->getChatUserTurnCount((int)$iAgentId, $aParams), $iRequestTurns)) {
-            $this->emitConversationClosed('limit', '', $aAgent, $aParams);
-            $this->emitChatLimitSse($oAdapter, $fEmit, $this->getChatLimitMessage($aAgent));
-            error_clear_last();
-            @ini_set('display_errors', '0');
-            exit;
-        }
-
-        $sPrompt = $this->applyChatInputLimit((string)$sPrompt, $aAgent ?: []);
-
-        $bStarted = false;
-        $aSseState = $this->resetChatActionsSseState();
-        $o = null;
-        try {
-            $o = self::getAgentInstance((int)$iAgentId, $aParams);
-            $oHandler = $o->stream(new NeuronAI\Chat\Messages\UserMessage($sPrompt));
-
-            foreach ($oHandler->events($oAdapter) as $sEvent) {
-                $bStarted = true;
-                $this->processChatActionsSseEvent($sEvent, $aSseState, $fEmit);
-            }
-
-            $this->flushChatActionsSseState($aSseState, $fEmit);
-            $this->persistAssistantChatActions($o);
-
-            if ($aAgent && $this->isChatTurnLimitReached($aAgent, $this->getChatUserTurnCount((int)$iAgentId, $aParams)))
-                $this->emitConversationClosed('limit', '', $aAgent, $aParams);
-        } catch (Throwable $oException) {
-            bx_log('sys_agents', "Stream exception for agent {$iAgentId}: " . $oException->getMessage() . " INPUT:" . $sPrompt);
-            $this->flushChatActionsSseState($aSseState, $fEmit);
-            if (!$bStarted) {
-                foreach ($oAdapter->start() as $sEvent)
-                    $fEmit($sEvent);
-            }
-            $sMsg = $oException->getMessage();
-            $fEmit('data: ' . json_encode(['type' => 'RUN_ERROR', 'message' => $sMsg !== '' ? $sMsg : _t('_sys_agents_exception')]) . "\n\n");
-            foreach ($oAdapter->end() as $sEvent)
-                $fEmit($sEvent);
-        }
-
-        error_clear_last();
-        @ini_set('display_errors', '0');
-        exit;
-    }
-
-    /**
-     * SSE assistant reply without calling the model. Used when max_turns is hit.
-     */
-    protected function emitChatLimitSse($oAdapter, $fEmit, $sMessage)
-    {
-        foreach ($oAdapter->start() as $sEvent)
-            $fEmit($sEvent);
-
-        $oChunk = new NeuronAI\Chat\Messages\Stream\Chunks\TextChunk('msg_limit', (string)$sMessage);
-        foreach ($oAdapter->transform($oChunk) as $sEvent)
-            $fEmit($sEvent);
-
-        foreach ($oAdapter->end() as $sEvent)
-            $fEmit($sEvent);
+        return BxDolAiTrigger::getInstance('chat')->stream($iAgentId, $sPrompt, $aParams, $sThreadId);
     }
 
-    /**
-     * SSE error without an assistant bubble. Used when a new session is rate-limited.
-     */
-    protected function emitChatErrorSse($oAdapter, $fEmit, $sMessage)
+    public function sendMessengerMessage($iSender, $iRecipient, $sMsg)
     {
-        foreach ($oAdapter->start() as $sEvent)
-            $fEmit($sEvent);
-
-        $sMsg = trim((string)$sMessage);
-        $fEmit('data: ' . json_encode([
-            'type' => 'RUN_ERROR',
-            'message' => $sMsg !== '' ? $sMsg : _t('_sys_agents_exception'),
-        ]) . "\n\n");
-
-        foreach ($oAdapter->end() as $sEvent)
-            $fEmit($sEvent);
-    }
-
-    public function sendMessengerMessage ($iSender, $iRecipient, $sMsg) 
-    {        
-        $oMessengerModule = BxDolModule::getInstance('bx_messenger');
-
-        $aAutoReplyData = [
-            'message' => $sMsg,
-            'participants' => [$iSender, $iRecipient],
-        ];
-
-        $iSaveProfileId = $oMessengerModule->setProfileId($iSender);
-        $a = $oMessengerModule->sendMessage($aAutoReplyData, $iRecipient, $iSender);
-        $oMessengerModule->setProfileId($iSaveProfileId);
-
-        return $a;
+        return BxDolAiTrigger::getInstance('message')->sendMessengerMessage($iSender, $iRecipient, $sMsg);
     }
 
     public function getAgentsByAlertUnitAndAction($sUnit, $sAction)
     {
-        $aAgents = [];
-        $a = $this->_oDb->getAgentsWithAlert();
-        foreach ($a as $r) {
-            $aAlert = explode(':', $r['alert']); // TODO: remake to concantenate $sUnit and $sAction and then compare
-            if (count($aAlert) == 2 && $aAlert[0] == $sUnit && $aAlert[1] == $sAction)
-                $aAgents[] = $r;
-        }
-
-        return $aAgents;
+        return BxDolAiTrigger::getInstance('alert')->getAgentsByUnitAndAction($sUnit, $sAction);
     }
 
     public function getAgentsBy($aParams)
@@ -1403,9 +1006,9 @@ class BxDolAi extends BxDolFactory implements iBxDolSingleton
 
     public function getAgentsByProfileId($iProfileId)
     {
-        return $this->_oDb->getAgentsByProfileId($iProfileId);
+        return BxDolAiTrigger::getInstance('message')->getAgentsByProfileId($iProfileId);
     }
-    
+
     public function getAgentsByFormObject($sFormObject)
     {
         return $this->_oDb->getAgentsByFormObject($sFormObject);
@@ -1418,6 +1021,8 @@ class BxDolAi extends BxDolFactory implements iBxDolSingleton
 
     public function getAgentByTriggerWebhookKey($sKey)
     {
-        return $this->_oDb->getAgentByTriggerWebhookKey($sKey);
+        return BxDolAiTrigger::getInstance('webhook')->getAgentByKey($sKey);
     }
 }
+
+/** @} */

+ 1 - 61
inc/classes/BxDolAiAlertResponse.php

@@ -9,68 +9,8 @@
  * @{
  */
 
-class BxDolAiAlertResponse extends BxDolAlertsResponse
+class BxDolAiAlertResponse extends BxDolAiTriggerMessage
 {
-    public function response($oAlert)
-    {        
-        if ('bx_messenger' == $oAlert->sUnit && 'got_jot' == $oAlert->sAction) {
-            $iProfileIdSender = $oAlert->iSender;
-            $this->processMesssage ($oAlert->iSender, $oAlert->aExtras['recipient_id'], $oAlert->iObject, $oAlert->aExtras['subobject_id'], $oAlert->aExtras['subobject_info']);
-        }
-    }
-
-    protected function processMesssage ($iSender, $iRecipient, $iLotId, $iJotId, $aJotInfo) 
-    {
-        if ($iSender == $iRecipient)
-            return;
-
-        // call agents
-        $oAi = BxDolAi::getInstance();
-        if($aAgents = $oAi->getAgentsByProfileId($iRecipient)) {
-            $GLOBALS['glAgentsCallQueue'] = [];
-            foreach($aAgents as $a) {
-                if (!$oAi->canInteract($a, $iSender))
-                    continue;
-                if (!$a['message_profile_id'] || $iSender == $a['message_profile_id']) {
-
-                    $aParams = [
-                        'trigger' => 'message',
-                        'sender_profile_id' => $iSender,
-                        'recipient_profile_id' => $iRecipient,
-                        'message_lot_id' => $iLotId, 
-                        'message_id' => $iJotId,
-                        'message_text' => $aJotInfo['message'],
-                        'message_info' => $aJotInfo,
-                    ];
-                    if ($a['async']) {
-                        BxDolBackgroundJobs::getInstance()->add(bin2hex(random_bytes(16)), [
-                            'system', 'call_agent', 
-                            ['message', $a, $aParams], 
-                            'TemplServices'
-                        ]);
-                    }
-                    else {
-                        $GLOBALS['glAgentsCallQueue'][] = [
-                            'type' => 'message',
-                            'agent' => $a,
-                            'params' => $aParams,
-                        ];
-                    }
-                }
-            }
-            if (!empty($GLOBALS['glAgentsCallQueue'])) {
-                ignore_user_abort(true); 
-                set_time_limit(0);
-
-                // TODO: need a way to force messenger to show new message from the server side
-                // register_shutdown_function(function () { 
-                //     bx_ai_process_agents_call_queue();
-                // });
-                bx_ai_process_agents_call_queue(false, false); // TODO: for a while do synchronically
-            }
-
-        }
-    }
 }
 
 /** @} */

+ 1 - 1
inc/classes/BxDolAiQuery.php

@@ -222,7 +222,7 @@ class BxDolAiQuery extends BxDolDb
 
     public function getAgentByTriggerWebhookKey($sKey, $bActiveOnly = true)
     {
-        return $this->getRow("SELECT * FROM `sys_agents_agents` WHERE `trigger` = 'scheduler' AND `webhook_key` = :key AND `active` = :active", ['key' => $sKey, 'active' => $bActiveOnly ? 1 : 0]);
+        return $this->getRow("SELECT * FROM `sys_agents_agents` WHERE `trigger` = 'webhook' AND `webhook_key` = :key AND `active` = :active", ['key' => $sKey, 'active' => $bActiveOnly ? 1 : 0]);
     }
     
     public function getAgentsByTriggerType($sTrigger, $bActiveOnly = true)

+ 179 - 0
inc/classes/BxDolAiTrigger.php

@@ -0,0 +1,179 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+interface iBxDolAiTrigger
+{
+    public function getType();
+
+    /**
+     * Host entry point (alert, cron, webhook, HTTP).
+     * @param mixed $mixed host-specific context
+     */
+    public function handle($mixed = null);
+
+    /**
+     * Run one agent. Used by handle() and background jobs.
+     */
+    public function call($aAgent, $mixedParams = []);
+}
+
+class BxDolAiTrigger extends BxDol implements iBxDolAiTrigger
+{
+    protected $_sType = '';
+
+    public static function getInstance($sType)
+    {
+        if ('chat' === $sType)
+            $sType = 'manual';
+
+        $aMap = [
+            'alert' => 'BxDolAiTriggerAlert',
+            'message' => 'BxDolAiTriggerMessage',
+            'webhook' => 'BxDolAiTriggerWebhook',
+            'scheduler' => 'BxDolAiTriggerScheduler',
+            'form-input' => 'BxDolAiTriggerFormInput',
+            'manual' => 'BxDolAiTriggerChat',
+        ];
+
+        $sClass = $aMap[$sType] ?? 'BxDolAiTrigger';
+        $sKey = __CLASS__ . '_' . $sType;
+        if (!isset($GLOBALS['bxDolClasses'][$sKey])) {
+            $o = new $sClass();
+            if ('BxDolAiTrigger' === $sClass)
+                $o->_sType = $sType;
+            $GLOBALS['bxDolClasses'][$sKey] = $o;
+        }
+
+        return $GLOBALS['bxDolClasses'][$sKey];
+    }
+
+    public function getType()
+    {
+        return $this->_sType;
+    }
+
+    public function handle($mixed = null)
+    {
+        return false;
+    }
+
+    public function call($aAgent, $mixedParams = [])
+    {
+        $oAi = $this->getAi();
+        if (!$oAi)
+            return false;
+
+        if ($mixedParams)
+            $sParams = is_string($mixedParams) ? $mixedParams : json_encode($mixedParams);
+        else
+            $sParams = 'START';
+
+        $sSampleField = $this->getSampleField();
+        if ($sSampleField && empty($aAgent[$sSampleField])) {
+            $oDb = new BxDolAiQuery();
+            $oDb->updateAgentField($aAgent['id'], $sSampleField, $sParams);
+        }
+
+        $aParams = $this->getCallChatHistoryParams($aAgent, $mixedParams);
+        $sParams = $this->limitCallInput($aAgent, $mixedParams, $sParams);
+
+        if ($this->appliesChatLimits()) {
+            if ($oAi->isChatTurnLimitReached($aAgent, $oAi->getChatUserTurnCount($aAgent['id'], $aParams))) {
+                $oAi->emitConversationClosed('limit', '', $aAgent, $aParams);
+                return $oAi->getChatLimitMessage($aAgent);
+            }
+
+            if ($oAi->isChatSessionRateLimited($aAgent, $aParams))
+                throw new Exception($oAi->getChatSessionRateLimitError());
+        }
+
+        $oAi->setChatContext($aAgent, $aParams);
+
+        $mixed = '';
+        try {
+            $o = BxDolAi::getAgentInstance($aAgent['id'], $aParams);
+            if (!$o)
+                return false;
+
+            $oMessage = $o->chat(new NeuronAI\Chat\Messages\UserMessage($sParams))->getMessage();
+            $mixed = $oMessage->getContent();
+        } catch (Exception $exception) {
+            bx_log('sys_agents', "Exception in '{$aAgent['name']}' agent: " . $exception->getMessage() . " INPUT:" . $sParams, BX_LOG_ERR);
+            $mixed = _t('_sys_agents_exception');
+        }
+
+        return $mixed;
+    }
+
+    protected function getAi()
+    {
+        return BxDolAi::getInstance();
+    }
+
+    protected function getSampleField()
+    {
+        return '';
+    }
+
+    protected function usesSenderChatHistory()
+    {
+        return false;
+    }
+
+    protected function appliesChatLimits()
+    {
+        return false;
+    }
+
+    protected function getCallChatHistoryParams($aAgent, $mixedParams)
+    {
+        if (!$this->usesSenderChatHistory() || !is_array($mixedParams))
+            return [];
+
+        return ['chat_history_subindex' => (int)($mixedParams['sender_profile_id'] ?? 0)];
+    }
+
+    protected function limitCallInput($aAgent, $mixedParams, $sParams)
+    {
+        if (!$this->appliesChatLimits())
+            return $sParams;
+
+        $oAi = $this->getAi();
+        if (is_array($mixedParams) && isset($mixedParams['message_text'])) {
+            $mixedParams['message_text'] = $oAi->applyChatInputLimit((string)$mixedParams['message_text'], $aAgent);
+            return json_encode($mixedParams);
+        }
+
+        if (is_string($mixedParams))
+            return $oAi->applyChatInputLimit($sParams, $aAgent);
+
+        return $sParams;
+    }
+
+    protected function enqueue($aAgent, $mixedParams)
+    {
+        BxDolBackgroundJobs::getInstance()->add(bin2hex(random_bytes(16)), [
+            'system', 'call_agent',
+            [$this->getType(), $aAgent, $mixedParams],
+            'TemplServices'
+        ]);
+    }
+
+    protected function runOrEnqueue($aAgent, $mixedParams)
+    {
+        if (!empty($aAgent['async'])) {
+            $this->enqueue($aAgent, $mixedParams);
+            return null;
+        }
+
+        return $this->call($aAgent, $mixedParams);
+    }
+}
+
+/** @} */

+ 82 - 0
inc/classes/BxDolAiTriggerAlert.php

@@ -0,0 +1,82 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAiTriggerAlert extends BxDolAiTrigger
+{
+    protected $_sType = 'alert';
+
+    protected function usesSenderChatHistory()
+    {
+        return true;
+    }
+
+    /**
+     * Sync: mutate alert extras from the model JSON. Async: background job.
+     */
+    public function handle($mixed = null)
+    {
+        $oAlert = $mixed;
+        if (!$oAlert || !BxDolAi::getInstance())
+            return false;
+
+        $aAgents = $this->getAgentsByUnitAndAction($oAlert->sUnit, $oAlert->sAction);
+        if (!$aAgents)
+            return false;
+
+        foreach ($aAgents as $a) {
+            $aParams = [
+                'trigger' => $this->getType(),
+                'object_id' => $oAlert->iObject,
+                'sender_profile_id' => $oAlert->iSender,
+                'unit' => $oAlert->sUnit,
+                'action' => $oAlert->sAction,
+                'extra' => $oAlert->aExtras,
+            ];
+
+            if (!empty($a['async'])) {
+                $this->enqueue($a, $aParams);
+                continue;
+            }
+
+            $this->applyExtraMutation($oAlert, $this->call($a, $aParams));
+        }
+
+        return true;
+    }
+
+    public function getAgentsByUnitAndAction($sUnit, $sAction)
+    {
+        $aAgents = [];
+        $oDb = new BxDolAiQuery();
+        foreach ($oDb->getAgentsWithAlert() as $r) {
+            $aAlert = explode(':', $r['alert']); // TODO: remake to concantenate $sUnit and $sAction and then compare
+            if (count($aAlert) == 2 && $aAlert[0] == $sUnit && $aAlert[1] == $sAction)
+                $aAgents[] = $r;
+        }
+
+        return $aAgents;
+    }
+
+    protected function applyExtraMutation($oAlert, $sExtraJSON)
+    {
+        $aExtra = [];
+        if ($sExtraJSON && 0 === bx_mb_strpos($sExtraJSON, '{'))
+            $aExtra = json_decode($sExtraJSON, true);
+
+        if (!$aExtra || !is_array($aExtra))
+            return;
+
+        foreach ($aExtra as $k => $v) {
+            if (isset($oAlert->aExtras[$k]))
+                $oAlert->aExtras[$k] = $v;
+        }
+    }
+}
+
+/** @} */

+ 485 - 0
inc/classes/BxDolAiTriggerChat.php

@@ -0,0 +1,485 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAiTriggerChat extends BxDolAiTrigger
+{
+    protected $_sType = 'manual';
+
+    protected function appliesChatLimits()
+    {
+        return true;
+    }
+
+    /**
+     * HTTP/SSE only. GET hydrates transcript; POST streams (or falls back to call()).
+     */
+    public function handle($mixed = null)
+    {
+        $iAgentId = (int)$mixed;
+        $sMethod = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : '';
+        if ('GET' === $sMethod)
+            return $this->hydrate($iAgentId);
+
+        if ('POST' !== $sMethod)
+            return echoJson(['code' => 405, 'msg' => _t('_error occured')]);
+
+        $aAgent = $this->loadAgent($iAgentId);
+        if (!$aAgent)
+            return echoJson(['code' => $iAgentId ? 404 : 400, 'msg' => _t('_sys_agents_agent_not_found')]);
+
+        $oAi = $this->getAi();
+        if (!$oAi)
+            return echoJson(['code' => 503, 'msg' => _t('_sys_agents_exception')]);
+
+        if (!$oAi->canChatDirectly($aAgent))
+            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized')]);
+
+        $sJson = file_get_contents('php://input');
+        $aData = json_decode($sJson, true);
+        if (json_last_error() !== JSON_ERROR_NONE)
+            return echoJson(['code' => 400, 'msg' => _t('_sys_agents_json_field_err')]);
+
+        $sPrompt = $this->extractPromptFromRequest($aData);
+        if (!$sPrompt)
+            return echoJson(['code' => 400, 'msg' => _t('_sys_agents_json_field_err')]);
+
+        $aParams = $this->historyParams($aAgent);
+        if (!$aParams)
+            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized')]);
+
+        $aParams['request_user_turns'] = $oAi->countChatUserTurns($aData['messages'] ?? []);
+
+        $sThreadId = !empty($aData['threadId']) ? $aData['threadId'] : null;
+
+        if (class_exists('NeuronAI\Chat\Messages\Stream\Adapters\AGUIAdapter')) {
+            $this->stream($aAgent['id'], $sPrompt, $aParams, $sThreadId);
+            return;
+        }
+
+        try {
+            $sReply = $this->call($aAgent, $sPrompt);
+        } catch (Throwable $o) {
+            return echoJson(['code' => 500, 'msg' => $o->getMessage(), 'messages' => []]);
+        }
+        return echoJson(['code' => 200, 'msg' => $sReply, 'text' => $sReply]);
+    }
+
+    public function extractPromptFromRequest($aData)
+    {
+        if (!is_array($aData))
+            return '';
+
+        if (!empty($aData['prompt']) && is_string($aData['prompt']))
+            return trim($aData['prompt']);
+
+        if (empty($aData['messages']) || !is_array($aData['messages']))
+            return '';
+
+        for ($i = count($aData['messages']) - 1; $i >= 0; $i--) {
+            $aMessage = $aData['messages'][$i];
+            if (!is_array($aMessage))
+                continue;
+
+            if (($aMessage['role'] ?? '') !== 'user')
+                continue;
+
+            if (isset($aMessage['content']) && is_string($aMessage['content']))
+                return trim($aMessage['content']);
+
+            if (!empty($aMessage['parts']) && is_array($aMessage['parts'])) {
+                $aText = [];
+                foreach ($aMessage['parts'] as $aPart) {
+                    if (is_array($aPart) && ($aPart['type'] ?? '') === 'text' && isset($aPart['content']))
+                        $aText[] = $aPart['content'];
+                }
+                $s = trim(implode("\n", $aText));
+                if ($s !== '')
+                    return $s;
+            }
+        }
+
+        return '';
+    }
+
+    public function stream($iAgentId, $sPrompt, $aParams = [], $sThreadId = null)
+    {
+        $oAi = $this->getAi();
+        if ($oAi && !isset($aParams['chat_history_subindex']))
+            $aParams = array_merge($oAi->resolveChatHistoryParams($iAgentId), $aParams);
+
+        // Staging header.inc.php dumps HTML on fatals and warns on null error_get_last().
+        // Any HTML after SSE is parsed by TanStack as "unterminated trailing data".
+        @ini_set('display_errors', '0');
+        @ini_set('zlib.output_compression', '0');
+        @ini_set('implicit_flush', '1');
+        if (function_exists('apache_setenv'))
+            @apache_setenv('no-gzip', '1');
+        while (ob_get_level())
+            ob_end_clean();
+
+        // UNA BxDolDb::pdoExceptionHandler dumps HTML 503 on uncaught PDOException.
+        restore_exception_handler();
+
+        $oAdapter = new NeuronAI\Chat\Messages\Stream\Adapters\AGUIAdapter($sThreadId);
+
+        http_response_code(200);
+        foreach ($oAdapter->getHeaders() as $sName => $sValue)
+            header($sName . ': ' . $sValue);
+
+        $fEmit = function ($sEvent) {
+            echo $sEvent;
+            if (ob_get_level())
+                ob_flush();
+            flush();
+        };
+
+        $aAgent = BxDolAiQuery::getAgentObject((int)$iAgentId);
+        if ($aAgent && $oAi)
+            $oAi->setChatContext($aAgent, $aParams);
+
+        if ($aAgent && $oAi && $oAi->isChatSessionRateLimited($aAgent, $aParams)) {
+            $this->emitErrorSse($oAdapter, $fEmit, $oAi->getChatSessionRateLimitError());
+            error_clear_last();
+            @ini_set('display_errors', '0');
+            exit;
+        }
+
+        $iRequestTurns = (int)($aParams['request_user_turns'] ?? 0);
+        if ($aAgent && $oAi && $oAi->isChatTurnLimitReached($aAgent, $oAi->getChatUserTurnCount((int)$iAgentId, $aParams), $iRequestTurns)) {
+            $oAi->emitConversationClosed('limit', '', $aAgent, $aParams);
+            $this->emitLimitSse($oAdapter, $fEmit, $oAi->getChatLimitMessage($aAgent));
+            error_clear_last();
+            @ini_set('display_errors', '0');
+            exit;
+        }
+
+        $sPrompt = $oAi ? $oAi->applyChatInputLimit((string)$sPrompt, $aAgent ?: []) : (string)$sPrompt;
+
+        $bStarted = false;
+        $aSseState = $this->resetActionsSseState();
+        $o = null;
+        try {
+            $o = BxDolAi::getAgentInstance((int)$iAgentId, $aParams);
+            $oHandler = $o->stream(new NeuronAI\Chat\Messages\UserMessage($sPrompt));
+
+            foreach ($oHandler->events($oAdapter) as $sEvent) {
+                $bStarted = true;
+                $this->processActionsSseEvent($sEvent, $aSseState, $fEmit);
+            }
+
+            $this->flushActionsSseState($aSseState, $fEmit);
+            $this->persistAssistantActions($o);
+
+            if ($aAgent && $oAi && $oAi->isChatTurnLimitReached($aAgent, $oAi->getChatUserTurnCount((int)$iAgentId, $aParams)))
+                $oAi->emitConversationClosed('limit', '', $aAgent, $aParams);
+        } catch (Throwable $oException) {
+            bx_log('sys_agents', "Stream exception for agent {$iAgentId}: " . $oException->getMessage() . " INPUT:" . $sPrompt);
+            $this->flushActionsSseState($aSseState, $fEmit);
+            if (!$bStarted) {
+                foreach ($oAdapter->start() as $sEvent)
+                    $fEmit($sEvent);
+            }
+            $sMsg = $oException->getMessage();
+            $fEmit('data: ' . json_encode(['type' => 'RUN_ERROR', 'message' => $sMsg !== '' ? $sMsg : _t('_sys_agents_exception')]) . "\n\n");
+            foreach ($oAdapter->end() as $sEvent)
+                $fEmit($sEvent);
+        }
+
+        error_clear_last();
+        @ini_set('display_errors', '0');
+        exit;
+    }
+
+    protected function hydrate($iAgentId)
+    {
+        if (false !== bx_get('offset'))
+            return echoJson(['code' => 405, 'msg' => _t('_error occured'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
+
+        $aAgent = $this->loadAgent($iAgentId);
+        if (!$aAgent)
+            return echoJson(['code' => (int)$iAgentId ? 404 : 400, 'msg' => _t('_sys_agents_agent_not_found'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
+
+        $oAi = $this->getAi();
+        if (!$oAi)
+            return echoJson(['code' => 503, 'msg' => _t('_sys_agents_exception'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
+
+        if (!$oAi->canChatDirectly($aAgent))
+            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
+
+        $aParams = $this->historyParams($aAgent);
+        if (!$aParams)
+            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
+
+        try {
+            $aMessages = $oAi->getChatHistoryUiMessages($aAgent['id'], $aParams);
+        } catch (Throwable $o) {
+            $aMessages = [];
+        }
+
+        if (!is_array($aMessages))
+            $aMessages = [];
+
+        return echoJson([
+            'messages' => $aMessages,
+            'activeRun' => null,
+            'interrupts' => null,
+        ]);
+    }
+
+    /**
+     * Member/guest subindex + optional `?context=` (group/org profile id).
+     * Invalid context is 403 — never fall back to the site-wide thread.
+     */
+    protected function historyParams($aAgent)
+    {
+        $oAi = $this->getAi();
+        $mixedContext = $oAi->resolveChatHistoryContextPid();
+        if ($mixedContext === false)
+            return false;
+
+        $aParams = $oAi->resolveChatHistoryParams((int)$aAgent['id']);
+        $aParams['chat_history_context_pid'] = (int)$mixedContext;
+        return $aParams;
+    }
+
+    protected function loadAgent($iAgentId)
+    {
+        $iAgentId = (int)$iAgentId;
+        if (!$iAgentId)
+            return false;
+
+        $aAgent = BxDolAiQuery::getAgentObject($iAgentId);
+        if (!$aAgent || empty($aAgent['active']))
+            return false;
+
+        return $aAgent;
+    }
+
+    protected function sseEvent($aPayload)
+    {
+        return 'data: ' . json_encode($aPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n\n";
+    }
+
+    protected function parseSseEvent($sEvent)
+    {
+        $s = trim((string)$sEvent);
+        if (!preg_match('/^data:\s*(.+)$/s', $s, $aM))
+            return null;
+
+        $a = json_decode($aM[1], true);
+        return is_array($a) ? $a : null;
+    }
+
+    protected function resetActionsSseState()
+    {
+        return [
+            'mode' => 'undecided',
+            'buf' => '',
+            'messageId' => '',
+            'held' => [],
+        ];
+    }
+
+    protected function emitHeldActionsSse(&$aState, $fEmit)
+    {
+        foreach ($aState['held'] as $sHeld)
+            $fEmit($sHeld);
+        $aState['held'] = [];
+    }
+
+    protected function emitParsedActionsSse(&$aState, $fEmit, $aParsed)
+    {
+        $sId = $aState['messageId'] !== '' ? $aState['messageId'] : ('msg_' . uniqid());
+        $fEmit($this->sseEvent([
+            'type' => 'TEXT_MESSAGE_START',
+            'messageId' => $sId,
+            'role' => 'assistant',
+        ]));
+        if ($aParsed['content'] !== '') {
+            $fEmit($this->sseEvent([
+                'type' => 'TEXT_MESSAGE_CONTENT',
+                'messageId' => $sId,
+                'delta' => $aParsed['content'],
+            ]));
+        }
+        $fEmit($this->sseEvent([
+            'type' => 'TEXT_MESSAGE_END',
+            'messageId' => $sId,
+        ]));
+        if (!empty($aParsed['actions'])) {
+            $fEmit($this->sseEvent([
+                'type' => 'CUSTOM',
+                'name' => 'chat_actions',
+                'messageId' => $sId,
+                'value' => $aParsed['actions'],
+            ]));
+        }
+        $aState = $this->resetActionsSseState();
+    }
+
+    protected function processActionsSseEvent($sEvent, &$aState, $fEmit)
+    {
+        $oAi = $this->getAi();
+        $aPayload = $this->parseSseEvent($sEvent);
+        $sType = is_array($aPayload) ? (string)($aPayload['type'] ?? '') : '';
+
+        if ($sType === 'TEXT_MESSAGE_START') {
+            if ($aState['held'] || $aState['buf'] !== '')
+                $this->flushActionsSseState($aState, $fEmit);
+            $aState['messageId'] = (string)($aPayload['messageId'] ?? '');
+            $aState['held'][] = $sEvent;
+            return;
+        }
+
+        if ($sType === 'TEXT_MESSAGE_CONTENT') {
+            $sDelta = (string)($aPayload['delta'] ?? '');
+            $aState['buf'] .= $sDelta;
+            if ($aState['mode'] === 'text') {
+                $fEmit($sEvent);
+                return;
+            }
+            if ($aState['mode'] === 'undecided') {
+                $mixed = $this->actionsStreamLooksLikeJson($aState['buf']);
+                if ($mixed === false) {
+                    $aState['mode'] = 'text';
+                    $this->emitHeldActionsSse($aState, $fEmit);
+                    $fEmit($sEvent);
+                    return;
+                }
+                if ($mixed === true)
+                    $aState['mode'] = 'json';
+            }
+            $aState['held'][] = $sEvent;
+            return;
+        }
+
+        if ($sType === 'TEXT_MESSAGE_END') {
+            $aParsed = $oAi->parseAssistantChatPayload($aState['buf']);
+            if ($aParsed && ($aState['mode'] === 'json' || $aState['mode'] === 'undecided')) {
+                $this->emitParsedActionsSse($aState, $fEmit, $aParsed);
+                return;
+            }
+            $this->emitHeldActionsSse($aState, $fEmit);
+            $fEmit($sEvent);
+            $aState = $this->resetActionsSseState();
+            return;
+        }
+
+        if ($sType === 'RUN_ERROR' || $sType === 'RUN_FINISHED') {
+            $this->flushActionsSseState($aState, $fEmit);
+            $fEmit($sEvent);
+            return;
+        }
+
+        if ($aState['mode'] === 'json' || $aState['held'])
+            $this->flushActionsSseState($aState, $fEmit);
+
+        $fEmit($sEvent);
+    }
+
+    protected function actionsStreamLooksLikeJson($sBuf)
+    {
+        $s = ltrim((string)$sBuf);
+        if ($s === '')
+            return null;
+        $sFirst = $s[0];
+        if ($sFirst === '{' || $sFirst === '`')
+            return true;
+        return false;
+    }
+
+    protected function flushActionsSseState(&$aState, $fEmit)
+    {
+        $oAi = $this->getAi();
+        if ($aState['buf'] === '' && !$aState['held'])
+            return;
+
+        $aParsed = $oAi->parseAssistantChatPayload($aState['buf']);
+        if ($aParsed && $aState['mode'] !== 'text') {
+            $this->emitParsedActionsSse($aState, $fEmit, $aParsed);
+            return;
+        }
+
+        $this->emitHeldActionsSse($aState, $fEmit);
+        $aState = $this->resetActionsSseState();
+    }
+
+    protected function persistAssistantActions($oAgent)
+    {
+        $oAi = $this->getAi();
+        if (!is_object($oAgent) || !method_exists($oAgent, 'getChatHistory'))
+            return;
+
+        $oHistory = $oAgent->getChatHistory();
+        if (!($oHistory instanceof BxDolAiChatHistory) || !method_exists($oHistory, 'persistMessages'))
+            return;
+
+        $aMessages = $oHistory->getMessages();
+        for ($i = count($aMessages) - 1; $i >= 0; $i--) {
+            $oMessage = $aMessages[$i];
+            if (
+                $oMessage instanceof NeuronAI\Chat\Messages\ToolCallMessage
+                || $oMessage instanceof NeuronAI\Chat\Messages\ToolResultMessage
+            ) {
+                continue;
+            }
+
+            $sRole = $oMessage->getRole();
+            if ($sRole === 'model')
+                $sRole = 'assistant';
+            if ($sRole !== 'assistant')
+                return;
+
+            $aParsed = $oAi->parseAssistantChatPayload((string)$oMessage->getContent());
+            if (!$aParsed)
+                return;
+
+            $oMessage->setContents($aParsed['content']);
+            $oMessage->addMetadata('actions', $aParsed['actions']);
+            $oHistory->persistMessages();
+            return;
+        }
+    }
+
+    /**
+     * SSE assistant reply without calling the model. Used when max_turns is hit.
+     */
+    protected function emitLimitSse($oAdapter, $fEmit, $sMessage)
+    {
+        foreach ($oAdapter->start() as $sEvent)
+            $fEmit($sEvent);
+
+        $oChunk = new NeuronAI\Chat\Messages\Stream\Chunks\TextChunk('msg_limit', (string)$sMessage);
+        foreach ($oAdapter->transform($oChunk) as $sEvent)
+            $fEmit($sEvent);
+
+        foreach ($oAdapter->end() as $sEvent)
+            $fEmit($sEvent);
+    }
+
+    /**
+     * SSE error without an assistant bubble. Used when a new session is rate-limited.
+     */
+    protected function emitErrorSse($oAdapter, $fEmit, $sMessage)
+    {
+        foreach ($oAdapter->start() as $sEvent)
+            $fEmit($sEvent);
+
+        $sMsg = trim((string)$sMessage);
+        $fEmit('data: ' . json_encode([
+            'type' => 'RUN_ERROR',
+            'message' => $sMsg !== '' ? $sMsg : _t('_sys_agents_exception'),
+        ]) . "\n\n");
+
+        foreach ($oAdapter->end() as $sEvent)
+            $fEmit($sEvent);
+    }
+}
+
+/** @} */

+ 67 - 0
inc/classes/BxDolAiTriggerFormInput.php

@@ -0,0 +1,67 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAiTriggerFormInput extends BxDolAiTrigger
+{
+    protected $_sType = 'form-input';
+
+    protected function usesSenderChatHistory()
+    {
+        return true;
+    }
+
+    public function handle($mixed = null)
+    {
+        $iAgentId = (int)$mixed;
+        $sJson = file_get_contents('php://input');
+        $aData = json_decode($sJson, true);
+
+        if (json_last_error() !== JSON_ERROR_NONE)
+            return echoJson(['code' => 500, 'msg' => _t('_sys_agents_json_field_err')]);
+
+        $sPrompt = $aData['prompt'] ?? null;
+        $sInputName = $aData['input_name'] ?? null;
+        $aValues = $aData['values'] ?? [];
+
+        $aAgent = BxDolAiQuery::getAgentObject($iAgentId);
+        if (!$aAgent || !$aAgent['active'] || $aAgent['trigger'] !== $this->getType())
+            return echoJson(['code' => 404, 'msg' => _t('_sys_agents_agent_not_found')]);
+
+        $oAi = $this->getAi();
+        if (!$oAi || !$oAi->canInteract($aAgent))
+            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized')]);
+
+        $aParams = [
+            'sender_profile_id' => bx_get_logged_profile_id(),
+            'user_prompt' => $sPrompt,
+            'form_field_name' => $sInputName,
+            'form_values' => $aValues,
+        ];
+
+        return echoJson(['code' => 200, 'msg' => $this->call($aAgent, $aParams)]);
+    }
+
+    public function getAgentsForForm($sFormObject)
+    {
+        $oAi = $this->getAi();
+        if (!$oAi)
+            return [];
+
+        $oDb = new BxDolAiQuery();
+        $aAgents = $oDb->getAgentsByFormObject($sFormObject);
+        if (!$aAgents || !is_array($aAgents))
+            return [];
+
+        return array_filter($aAgents, function ($a) use ($oAi) {
+            return $oAi->canInteract($a);
+        });
+    }
+}
+
+/** @} */

+ 150 - 0
inc/classes/BxDolAiTriggerMessage.php

@@ -0,0 +1,150 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAiTriggerMessage extends BxDolAiTrigger
+{
+    protected $_sType = 'message';
+
+    protected function usesSenderChatHistory()
+    {
+        return true;
+    }
+
+    protected function appliesChatLimits()
+    {
+        return true;
+    }
+
+    public function response($oAlert)
+    {
+        BxDolAiTrigger::getInstance($this->getType())->handle($oAlert);
+    }
+
+    public function handle($mixed = null)
+    {
+        $oAlert = $mixed;
+        if (!$oAlert || 'bx_messenger' != $oAlert->sUnit || 'got_jot' != $oAlert->sAction)
+            return false;
+
+        return $this->processMessage(
+            $oAlert->iSender,
+            $oAlert->aExtras['recipient_id'],
+            $oAlert->iObject,
+            $oAlert->aExtras['subobject_id'],
+            $oAlert->aExtras['subobject_info']
+        );
+    }
+
+    public function getAgentsByProfileId($iProfileId)
+    {
+        $oDb = new BxDolAiQuery();
+        return $oDb->getAgentsByProfileId($iProfileId);
+    }
+
+    public function processCallQueue($bFinishRequest = true, $bExit = true)
+    {
+        if ($bFinishRequest) {
+            if (function_exists('fastcgi_finish_request')) {
+                fastcgi_finish_request();
+            } else {
+                @ob_end_flush();
+                @flush();
+            }
+        }
+
+        if (!empty($GLOBALS['glAgentsCallQueue'])) {
+            foreach ($GLOBALS['glAgentsCallQueue'] as $r) {
+                $sType = $r['type'] ?? $this->getType();
+                $sMessage = BxDolAiTrigger::getInstance($sType)->call($r['agent'], $r['params']);
+                if (null == $sMessage) {
+                    // TODO: maybe reply with some empty message
+                    continue;
+                }
+                $oParsedown = new Parsedown();
+                $oParsedown->setSafeMode(true);
+                $sMessageHtml = $oParsedown->text($sMessage);
+                $this->sendMessengerMessage($r['agent']['profile_id'], $r['params']['sender_profile_id'], str_replace('\n', '', $sMessageHtml));
+            }
+        }
+
+        if ($bExit)
+            exit(0);
+    }
+
+    public function sendMessengerMessage($iSender, $iRecipient, $sMsg)
+    {
+        $oMessengerModule = BxDolModule::getInstance('bx_messenger');
+
+        $aAutoReplyData = [
+            'message' => $sMsg,
+            'participants' => [$iSender, $iRecipient],
+        ];
+
+        $iSaveProfileId = $oMessengerModule->setProfileId($iSender);
+        $a = $oMessengerModule->sendMessage($aAutoReplyData, $iRecipient, $iSender);
+        $oMessengerModule->setProfileId($iSaveProfileId);
+
+        return $a;
+    }
+
+    protected function processMessage($iSender, $iRecipient, $iLotId, $iJotId, $aJotInfo)
+    {
+        if ($iSender == $iRecipient)
+            return false;
+
+        $oAi = $this->getAi();
+        if (!$oAi)
+            return false;
+
+        $aAgents = $this->getAgentsByProfileId($iRecipient);
+        if (!$aAgents)
+            return false;
+
+        $GLOBALS['glAgentsCallQueue'] = [];
+        foreach ($aAgents as $a) {
+            if (!$oAi->canInteract($a, $iSender))
+                continue;
+            if (!$a['message_profile_id'] || $iSender == $a['message_profile_id']) {
+                $aParams = [
+                    'trigger' => $this->getType(),
+                    'sender_profile_id' => $iSender,
+                    'recipient_profile_id' => $iRecipient,
+                    'message_lot_id' => $iLotId,
+                    'message_id' => $iJotId,
+                    'message_text' => $aJotInfo['message'],
+                    'message_info' => $aJotInfo,
+                ];
+                if ($a['async']) {
+                    $this->enqueue($a, $aParams);
+                } else {
+                    $GLOBALS['glAgentsCallQueue'][] = [
+                        'type' => $this->getType(),
+                        'agent' => $a,
+                        'params' => $aParams,
+                    ];
+                }
+            }
+        }
+
+        if (!empty($GLOBALS['glAgentsCallQueue'])) {
+            ignore_user_abort(true);
+            set_time_limit(0);
+
+            // TODO: need a way to force messenger to show new message from the server side
+            // register_shutdown_function(function () {
+            //     bx_ai_process_agents_call_queue();
+            // });
+            $this->processCallQueue(false, false); // TODO: for a while do synchronically
+        }
+
+        return true;
+    }
+}
+
+/** @} */

+ 39 - 0
inc/classes/BxDolAiTriggerScheduler.php

@@ -0,0 +1,39 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAiTriggerScheduler extends BxDolAiTrigger
+{
+    protected $_sType = 'scheduler';
+
+    public function handle($mixed = null)
+    {
+        if (!BxDolAi::getInstance())
+            return false;
+
+        $aDate = is_array($mixed) ? $mixed : getdate(time());
+        $aAgents = $this->getAgents();
+        if (!$aAgents)
+            return false;
+
+        foreach ($aAgents as $aAgent) {
+            if (function_exists('checkCronJob') && checkCronJob($aAgent['scheduler_cron'], $aDate))
+                $this->call($aAgent);
+        }
+
+        return true;
+    }
+
+    public function getAgents()
+    {
+        $oDb = new BxDolAiQuery();
+        return $oDb->getAgentsByTriggerType($this->getType());
+    }
+}
+
+/** @} */

+ 66 - 0
inc/classes/BxDolAiTriggerWebhook.php

@@ -0,0 +1,66 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAiTriggerWebhook extends BxDolAiTrigger
+{
+    protected $_sType = 'webhook';
+
+    protected function getSampleField()
+    {
+        return 'webhook_sample';
+    }
+
+    public function handle($mixed = null)
+    {
+        if (!BxDolAi::getInstance()) {
+            header('Content-Type: application/json');
+            header('HTTP/1.0 403 Forbidden');
+            BxDolLanguages::getInstance();
+            echo json_encode(['status' => 403, 'error' => _t("_Access denied")]);
+            exit;
+        }
+
+        header('Content-Type: application/json');
+
+        $aHeaders = function_exists('getallheaders') ? getallheaders() : false;
+        if ($aHeaders) {
+            $sAuthHeader = isset($aHeaders['Authorization']) ? $aHeaders['Authorization'] : (isset($aHeaders['authorization']) ? $aHeaders['authorization'] : false);
+        } else {
+            $sAuthHeader = isset($_SERVER['HTTP_AUTHORIZATION']) ? $_SERVER['HTTP_AUTHORIZATION'] : false;
+        }
+        $sKey = str_replace('Bearer ', '', $sAuthHeader);
+
+        $aAgent = $sKey ? $this->getAgentByKey($sKey) : false;
+        if (!$aAgent) {
+            header('HTTP/1.0 403 Forbidden');
+            BxDolLanguages::getInstance();
+            echo json_encode(['status' => 403, 'error' => _t("_Access denied")]);
+            exit;
+        }
+
+        $aParams = is_array($mixed) ? $mixed : $_REQUEST;
+        $aParams['trigger'] = $this->getType();
+
+        if (!empty($aAgent['async'])) {
+            $this->enqueue($aAgent, $aParams);
+            echo json_encode(['result' => 'scheduled']);
+            return;
+        }
+
+        echo json_encode($this->call($aAgent, $aParams));
+    }
+
+    public function getAgentByKey($sKey)
+    {
+        $oDb = new BxDolAiQuery();
+        return $oDb->getAgentByTriggerWebhookKey($sKey);
+    }
+}
+
+/** @} */

+ 2 - 34
inc/classes/BxDolAlerts.php

@@ -153,40 +153,8 @@ class BxDolAlerts extends BxDol
         BxDolAlertsStats::log($this);
 
         // call agents
-        $oAi = BxDolAi::getInstance();
-        if($aAgents = $oAi->getAgentsByAlertUnitAndAction($this->sUnit, $this->sAction)) {
-            foreach($aAgents as $a) {
-                $aParams = [
-                    'trigger' => 'alert',
-                    'object_id' => $this->iObject,
-                    'sender_profile_id' => $this->iSender,
-                    'unit' => $this->sUnit, 
-                    'action' => $this->sAction,
-                    'extra' => $this->aExtras,
-                ];
-                if ($a['async']) {
-                    BxDolBackgroundJobs::getInstance()->add(bin2hex(random_bytes(16)), [
-                        'system', 'call_agent', 
-                        ['alert', $a, $aParams], 
-                        'TemplServices'
-                    ]);
-                }
-                else {
-                    $sExtraJSON = $oAi->callAgent('alert', $a, $aParams);
-
-                    $aExtra = [];
-                    if ($sExtraJSON && 0 === bx_mb_strpos($sExtraJSON, '{'))
-                        $aExtra = json_decode($sExtraJSON, true);
-                    
-                    if ($aExtra && is_array($aExtra)) {
-                        foreach ($aExtra as $k => $v) {
-                            if (isset($this->aExtras[$k]))
-                                $this->aExtras[$k] = $v;
-                        }
-                    }
-                }
-            }
-        }
+        if (BxDolAi::getInstance())
+            BxDolAiTrigger::getInstance('alert')->handle($this);
     }
 
     /**

+ 3 - 29
inc/utils.inc.php

@@ -2931,35 +2931,9 @@ function bx_get_search_class_name(): string
 
 function bx_ai_process_agents_call_queue($bFinishRequest = true, $bExit = true)
 {
-    if ($bFinishRequest) {
-        if (function_exists('fastcgi_finish_request')) {
-            fastcgi_finish_request();
-        } else {
-            @ob_end_flush();
-            @flush();
-        }
-    }
-
-    if (!empty($GLOBALS['glAgentsCallQueue']))
-    {
-        $oAi = BxDolAi::getInstance();
-        foreach ($GLOBALS['glAgentsCallQueue'] as $r) 
-        {
-            $sMessage = $oAi->callAgent($r['type'], $r['agent'], $r['params']);
-            if (null == $sMessage) {
-                // TODO: maybe reply with some empty message
-                continue;
-            }
-            $oParsedown = new Parsedown();
-            $oParsedown->setSafeMode(true);
-            $sMessageHtml = $oParsedown->text($sMessage);
-            $oAi->sendMessengerMessage($r['agent']['profile_id'], $r['params']['sender_profile_id'], str_replace('\n', '', $sMessageHtml));
-        }
-    }
-    
-    if ($bExit) {
-        exit(0);
-    }
+    $oTrigger = BxDolAiTrigger::getInstance('message');
+    if ($oTrigger)
+        $oTrigger->processCallQueue($bFinishRequest, $bExit);
 }
 
 /** @} */

+ 1 - 1
install/sql/system.sql

@@ -2304,7 +2304,7 @@ INSERT INTO `sys_alerts` (`unit`, `action`, `handler_id`) VALUES
 
 
 INSERT INTO `sys_alerts_handlers` (`name`, `class`, `file`) VALUES
-('sys_agents', 'BxDolAiAlertResponse', 'inc/classes/BxDolAiAlertResponse.php');
+('sys_agents', 'BxDolAiTriggerMessage', 'inc/classes/BxDolAiTriggerMessage.php');
 SET @iIdHandler = LAST_INSERT_ID();
 
 INSERT INTO `sys_alerts` (`unit`, `action`, `handler_id`) VALUES

+ 2 - 6
periodic/cron.php

@@ -160,10 +160,6 @@ foreach($aJobs as $aRow) {
 }
 
 // run agents (schedulers)
-$oAi = BxDolAI::getInstance();
-$aAgents = $oAi->getAgentsByTriggerType('scheduler');
-foreach($aAgents as $aAgent) {
-    if(checkCronJob($aAgent['scheduler_cron'], $aDate))
-        $oAi->callAgent('scheduler', $aAgent);
-}
+if (BxDolAi::getInstance())
+    BxDolAiTrigger::getInstance('scheduler')->handle($aDate);
 /** @} */

+ 3 - 7
template/scripts/BxBaseFormView.php

@@ -154,13 +154,9 @@ class BxBaseFormView extends BxDolForm
         );
 
         $this->_sAgentFormObject = $aInfo['params']['object'] ?? $this->id;
-        $oAi = BxDolAI::getInstance();
-        $this->_aAgentsFormObject = $oAi ? $oAi->getAgentsByFormObject($this->_sAgentFormObject) : [];
-        if ($this->_aAgentsFormObject && is_array($this->_aAgentsFormObject)) {
-            $this->_aAgentsFormObject = array_filter($this->_aAgentsFormObject, function ($a) use ($oAi) {
-                return $oAi->canInteract($a);
-            });
-        }
+        $this->_aAgentsFormObject = BxDolAi::getInstance()
+            ? BxDolAiTrigger::getInstance('form-input')->getAgentsForForm($this->_sAgentFormObject)
+            : [];
     }
 
     public function performActionGetHelp()

+ 5 - 142
template/scripts/BxBaseServices.php

@@ -872,7 +872,7 @@ class BxBaseServices extends BxDol implements iBxDolProfileService
         if(!$aAgent || !is_array($aAgent))
             return $bIsApi ? [] : '';
 
-        $oAi = BxDolAI::getInstance();
+        $oAi = BxDolAi::getInstance();
         $bRequirePost = (bool)$bRequirePost;
         if ($mixedContextPid === false || $mixedContextPid === null || $mixedContextPid === '')
             $iContextPid = $oAi ? (int)$oAi->resolveChatHistoryContextPidFromPage($bRequirePost) : 0;
@@ -1588,7 +1588,7 @@ class BxBaseServices extends BxDol implements iBxDolProfileService
 
     public function serviceGetOptionsAgentsModel()
     {
-        return ['' => _t('_Select_one')] + BxDolAI::getInstance()->getModels(['active' => true]);
+        return ['' => _t('_Select_one')] + BxDolAi::getInstance()->getModels(['active' => true]);
     }
 
     public function serviceGetOptionsAgentsProfile($bSelectOne = true, $sSelectOneLangKey = '_Select_one')
@@ -1776,155 +1776,18 @@ class BxBaseServices extends BxDol implements iBxDolProfileService
 
     public function serviceCallAgent($sType, $aAgent, $aParams)
     {
-        $oAi = BxDolAI::getInstance();
+        $oAi = BxDolAi::getInstance();
         return $oAi->callAgent($sType, $aAgent, $aParams);
     }
 
     public function serviceCallAgentForFormInput($iAgentId)
     {
-        $sJson = file_get_contents('php://input');
-        $aData = json_decode($sJson, true);
-
-        if (json_last_error() !== JSON_ERROR_NONE)
-            return echoJson(['code' => 500, 'msg' => _t('_sys_agents_json_field_err')]);
-        
-        $sPrompt = $aData['prompt'] ?? null;
-        $sInputName = $aData['input_name'] ?? null;
-        $aValues = $aData['values'] ?? [];
-
-        $aAgent = BxDolAiQuery::getAgentObject($iAgentId);
-        if (!$aAgent || !$aAgent['active'] || $aAgent['trigger'] !== 'form-input')
-            return echoJson(['code' => 404, 'msg' => _t('_sys_agents_agent_not_found')]);
-
-        $oAi = BxDolAI::getInstance();
-        if (!$oAi || !$oAi->canInteract($aAgent))
-            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized')]);
-        
-        $aParams = [
-            'sender_profile_id' => bx_get_logged_profile_id(),
-            'user_prompt' => $sPrompt,
-            'form_field_name' => $sInputName,
-            'form_values' => $aValues,
-        ];
-
-        $mixed = $oAi->callAgent('form-input', $aAgent, $aParams);
-
-        return echoJson(['code' => 200, 'msg' => $mixed]);
+        return BxDolAiTrigger::getInstance('form-input')->handle($iAgentId);
     }
 
     public function serviceAiChat($iAgentId)
     {
-        $sMethod = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : '';
-        if('GET' === $sMethod)
-            return $this->_aiChatHydrate($iAgentId);
-
-        if('POST' !== $sMethod)
-            return echoJson(['code' => 405, 'msg' => _t('_error occured')]);
-
-        $aAgent = $this->_aiChatLoadAgent($iAgentId);
-        if(!$aAgent)
-            return echoJson(['code' => (int)$iAgentId ? 404 : 400, 'msg' => _t('_sys_agents_agent_not_found')]);
-
-        $oAi = BxDolAI::getInstance();
-        if(!$oAi)
-            return echoJson(['code' => 503, 'msg' => _t('_sys_agents_exception')]);
-
-        if(!$oAi->canChatDirectly($aAgent))
-            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized')]);
-
-        $sJson = file_get_contents('php://input');
-        $aData = json_decode($sJson, true);
-        if(json_last_error() !== JSON_ERROR_NONE)
-            return echoJson(['code' => 400, 'msg' => _t('_sys_agents_json_field_err')]);
-
-        $sPrompt = $oAi->extractChatPromptFromRequest($aData);
-        if(!$sPrompt)
-            return echoJson(['code' => 400, 'msg' => _t('_sys_agents_json_field_err')]);
-
-        $aParams = $this->_aiChatHistoryParams($aAgent);
-        if (!$aParams)
-            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized')]);
-
-        $aParams['request_user_turns'] = $oAi->countChatUserTurns($aData['messages'] ?? []);
-
-        $sThreadId = !empty($aData['threadId']) ? $aData['threadId'] : null;
-
-        if(class_exists('NeuronAI\Chat\Messages\Stream\Adapters\AGUIAdapter')) {
-            $oAi->streamAgentChat($aAgent['id'], $sPrompt, $aParams, $sThreadId);
-            return;
-        }
-
-        try {
-            $sReply = $oAi->callAgent('manual', $aAgent, $sPrompt);
-        } catch (Throwable $o) {
-            return echoJson(['code' => 500, 'msg' => $o->getMessage(), 'messages' => []]);
-        }
-        return echoJson(['code' => 200, 'msg' => $sReply, 'text' => $sReply]);
-    }
-
-    protected function _aiChatHydrate($iAgentId)
-    {
-        if(false !== bx_get('offset'))
-            return echoJson(['code' => 405, 'msg' => _t('_error occured'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
-
-        $aAgent = $this->_aiChatLoadAgent($iAgentId);
-        if(!$aAgent)
-            return echoJson(['code' => (int)$iAgentId ? 404 : 400, 'msg' => _t('_sys_agents_agent_not_found'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
-
-        $oAi = BxDolAI::getInstance();
-        if(!$oAi)
-            return echoJson(['code' => 503, 'msg' => _t('_sys_agents_exception'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
-
-        if(!$oAi->canChatDirectly($aAgent))
-            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
-
-        $aParams = $this->_aiChatHistoryParams($aAgent);
-        if (!$aParams)
-            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
-
-        try {
-            $aMessages = $oAi->getChatHistoryUiMessages($aAgent['id'], $aParams);
-        } catch (Throwable $o) {
-            $aMessages = [];
-        }
-
-        if(!is_array($aMessages))
-            $aMessages = [];
-
-        return echoJson([
-            'messages' => $aMessages,
-            'activeRun' => null,
-            'interrupts' => null,
-        ]);
-    }
-
-    /**
-     * Member/guest subindex + optional `?context=` (group/org profile id).
-     * Invalid context is 403 — never fall back to the site-wide thread.
-     */
-    protected function _aiChatHistoryParams($aAgent)
-    {
-        $oAi = BxDolAI::getInstance();
-        $mixedContext = $oAi->resolveChatHistoryContextPid();
-        if ($mixedContext === false)
-            return false;
-
-        $aParams = $oAi->resolveChatHistoryParams((int)$aAgent['id']);
-        $aParams['chat_history_context_pid'] = (int)$mixedContext;
-        return $aParams;
-    }
-
-    protected function _aiChatLoadAgent($iAgentId)
-    {
-        $iAgentId = (int)$iAgentId;
-        if (!$iAgentId)
-            return false;
-
-        $aAgent = BxDolAIQuery::getAgentObject($iAgentId);
-        if(!$aAgent || empty($aAgent['active']))
-            return false;
-
-        return $aAgent;
+        return BxDolAiTrigger::getInstance('chat')->handle($iAgentId);
     }
 }
 

+ 1 - 35
webhook.php

@@ -8,38 +8,4 @@ require_once(BX_DIRECTORY_PATH_INC . "design.inc.php");
 
 bx_import('BxDolLanguages');
 
-header('Content-Type: application/json');
-
-$aHeaders = function_exists('getallheaders') ? getallheaders() : false;
-if ($aHeaders) {
-    $sAuthHeader = isset($aHeaders['Authorization']) ? $aHeaders['Authorization'] : (isset($aHeaders['authorization']) ? $aHeaders['authorization'] : false);
-}
-else {
-    $sAuthHeader = isset($_SERVER['HTTP_AUTHORIZATION']) ? $_SERVER['HTTP_AUTHORIZATION'] : false;
-}
-$sKey = str_replace('Bearer ', '', $sAuthHeader);
-
-$oAi = BxDolAI::getInstance();
-$aAgent = $sKey ? $oAi->getAgentByTriggerWebhookKey($sKey) : false;
-if (!$aAgent) {
-    header('HTTP/1.0 403 Forbidden');
-    BxDolLanguages::getInstance();
-    echo json_encode(['status' => 403, 'error' => _t("_Access denied")]);
-    exit;
-}
-
-$aParams = $_REQUEST;
-$aParams['trigger'] = 'webhook';
-
-if ($aAgent['async']) {
-    BxDolBackgroundJobs::getInstance()->add(bin2hex(random_bytes(16)), [
-        'system', 'call_agent', 
-        ['webhook', $aAgent, $aParams], 
-        'TemplServices'
-    ]);
-    echo json_encode(['result' => 'scheduled']);
-}
-else {
-    $mixed = $oAi->callAgent('webhook', $aAgent, $aParams);
-    echo json_encode($mixed);
-}
+BxDolAiTrigger::getInstance('webhook')->handle();