Explorar el Código

Hidden/default tools for agents (#5911)

* Hidden tools
Aleksandr Trofimov hace 4 días
padre
commit
fd9bc9335a

+ 5 - 1
inc/classes/BxDolAIToolAgentCreate.php

@@ -206,6 +206,8 @@ class BxDolAIToolAgentCreate extends BxDolAITool
         $aTools = [];
         $aTools = [];
         $aToolRows = $oDb->getAll("SELECT `id`, `type`, `title`, `active` FROM `sys_agents_tools` ORDER BY `type` ASC");
         $aToolRows = $oDb->getAll("SELECT `id`, `type`, `title`, `active` FROM `sys_agents_tools` ORDER BY `type` ASC");
         foreach ($aToolRows as $aRow) {
         foreach ($aToolRows as $aRow) {
+            if (in_array($aRow['type'], BxDolAiAgentTools::hiddenTypes(), true))
+                continue;
             $sHay = mb_strtolower($aRow['id'] . ' ' . $aRow['type'] . ' ' . $aRow['title']);
             $sHay = mb_strtolower($aRow['id'] . ' ' . $aRow['type'] . ' ' . $aRow['title']);
             if ($sQ !== '' && strpos($sHay, $sQ) === false && $sPreset !== 'comment_reply')
             if ($sQ !== '' && strpos($sHay, $sQ) === false && $sPreset !== 'comment_reply')
                 continue;
                 continue;
@@ -965,7 +967,9 @@ class BxDolAIToolAgentCreate extends BxDolAITool
                 continue;
                 continue;
             }
             }
             if (in_array($aRow['type'], self::FORBIDDEN_TOOL_TYPES, true))
             if (in_array($aRow['type'], self::FORBIDDEN_TOOL_TYPES, true))
-                return ['ok' => 0, 'error' => "Refusing stock mysql_write on a created agent. Use mysql_write_safe if you really need writes."];
+                return ['ok' => 0, 'error' => 'Refusing stock mysql_write on a created agent.'];
+            if (in_array($aRow['type'], BxDolAiAgentTools::hiddenTypes(), true))
+                continue;
             $aIds[(int)$aRow['id']] = (int)$aRow['id'];
             $aIds[(int)$aRow['id']] = (int)$aRow['id'];
         }
         }
         if ($aMissing)
         if ($aMissing)

+ 7 - 13
inc/classes/BxDolAiAgent.php

@@ -77,20 +77,14 @@ class BxDolAiAgent extends RAG
      */
      */
     protected function tools(): array
     protected function tools(): array
     {
     {
-        if ($this->aAgent['tools']) {
-            $aTools = explode(',', $this->aAgent['tools']);
-            $aToolInstances = [];
-            foreach ($aTools as $sToolId) {
-                $iToolId = (int)trim($sToolId);
-                if ($iToolId <= 0)
-                    continue;
-                $aToolInstances[] = BxDolAIToolFactory::getToolInstance($iToolId);
-            }
-            return $aToolInstances;
-        }
-        else {
-            return [];
+        $aToolInstances = [];
+        foreach (BxDolAiAgentTools::resolveIds($this->aAgent) as $iToolId) {
+            if (!BxDolAiQuery::getToolObject($iToolId))
+                continue;
+            $aToolInstances[] = BxDolAIToolFactory::getToolInstance($iToolId);
         }
         }
+
+        return $aToolInstances;
     }
     }
     
     
     protected function vectorStore(): NeuronAI\RAG\VectorStore\VectorStoreInterface
     protected function vectorStore(): NeuronAI\RAG\VectorStore\VectorStoreInterface

+ 138 - 0
inc/classes/BxDolAiAgentTools.php

@@ -0,0 +1,138 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * Tools attached in code. They are not stored on the agent and are not offered
+ * in the Studio tool picker, the tools grid, or agent_create.
+ * mysql_schema and mysql_select are mandatory on una_operator and stay visible.
+ * mysql_write_safe and mysql_undo do not.
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAiAgentTools
+{
+    /** Every agent. */
+    public const ALWAYS = ['chat_buttons', 'chat_title'];
+
+    /** una_operator, in addition to ALWAYS. Hidden from the picker. mysql_write is not included. */
+    public const OPERATOR = [
+        'agent_create',
+        'mockup_upsert',
+        'mysql_write_safe',
+        'mysql_undo',
+    ];
+
+    /** una_operator, in addition to ALWAYS and OPERATOR. Visible, so other agents can keep them. */
+    public const OPERATOR_READ = ['mysql_schema', 'mysql_select'];
+
+    public static function typesFor(array $aAgent): array
+    {
+        $a = self::ALWAYS;
+        if (($aAgent['name'] ?? '') === 'una_operator')
+            $a = array_merge($a, self::OPERATOR, self::OPERATOR_READ);
+
+        return $a;
+    }
+
+    public static function hiddenTypes(): array
+    {
+        return array_values(array_unique(array_merge(self::ALWAYS, self::OPERATOR)));
+    }
+
+    /**
+     * Studio tools grid source, with locked types removed.
+     */
+    public static function excludeHiddenFromSource(string $sSource): string
+    {
+        if (stripos($sSource, ' WHERE ') === false)
+            $sSource .= ' WHERE 1 ';
+
+        return $sSource . ' AND `type` NOT IN (' . BxDolDb::getInstance()->implode_escape(self::hiddenTypes()) . ')';
+    }
+
+    /**
+     * One active row per type that this agent must have, lowest id.
+     *
+     * @return int[]
+     */
+    public static function idsFor(array $aAgent): array
+    {
+        $aTypes = self::typesFor($aAgent);
+        if (!$aTypes)
+            return [];
+
+        $aIds = [];
+        $aSeen = [];
+        foreach ((new BxDolAiQuery())->getActiveToolsByTypes($aTypes) as $aRow) {
+            $sType = (string)$aRow['type'];
+            if (isset($aSeen[$sType]))
+                continue;
+            $aSeen[$sType] = true;
+            $aIds[] = (int)$aRow['id'];
+        }
+
+        return $aIds;
+    }
+
+    /**
+     * Every row of a locked type, including inactive copies, so a stored id cannot grant one.
+     *
+     * @return int[]
+     */
+    public static function hiddenIds(): array
+    {
+        $aTypes = self::hiddenTypes();
+        if (!$aTypes)
+            return [];
+
+        return (new BxDolAiQuery())->getToolIdsByTypes($aTypes);
+    }
+
+    /**
+     * Stored ids minus locked ones, plus the ids this agent always has.
+     *
+     * @return int[]
+     */
+    public static function resolveIds(array $aAgent): array
+    {
+        $aIds = [];
+        if (!empty($aAgent['tools'])) {
+            foreach (explode(',', (string)$aAgent['tools']) as $sToolId) {
+                $iToolId = (int)trim($sToolId);
+                if ($iToolId > 0)
+                    $aIds[$iToolId] = $iToolId;
+            }
+        }
+
+        foreach (self::hiddenIds() as $iHidden)
+            unset($aIds[$iHidden]);
+
+        foreach (self::idsFor($aAgent) as $iId)
+            $aIds[$iId] = $iId;
+
+        return array_values($aIds);
+    }
+
+    public static function stripCsv(string $sTools): string
+    {
+        $sTools = trim($sTools);
+        if ($sTools === '')
+            return '';
+
+        $aHidden = array_flip(self::hiddenIds());
+        $aKeep = [];
+        foreach (explode(',', $sTools) as $sId) {
+            $iId = (int)trim($sId);
+            if ($iId <= 0 || isset($aHidden[$iId]))
+                continue;
+            $aKeep[$iId] = $iId;
+        }
+
+        return implode(',', $aKeep);
+    }
+}
+
+/** @} */

+ 43 - 2
inc/classes/BxDolAiQuery.php

@@ -261,8 +261,49 @@ class BxDolAiQuery extends BxDolDb
 
 
     public function getTools()
     public function getTools()
     {
     {
-        $sQuery = "SELECT `id`, `title` FROM `sys_agents_tools` WHERE `active` = 1 ORDER BY `title` ASC";
-        return $this->getPairs($sQuery, 'id', 'title'); 
+        $sHidden = $this->implode_escape(BxDolAiAgentTools::hiddenTypes());
+        $sQuery = "SELECT `id`, `title` FROM `sys_agents_tools` WHERE `active` = 1 AND `type` NOT IN (" . $sHidden . ") ORDER BY `title` ASC";
+        return $this->getPairs($sQuery, 'id', 'title');
+    }
+
+    /**
+     * Active tool rows for these types, lowest id first.
+     *
+     * @return array<int, array{id:int, type:string}>
+     */
+    public function getActiveToolsByTypes(array $aTypes): array
+    {
+        if (!$aTypes)
+            return [];
+
+        $aRows = $this->getAll("SELECT `id`, `type` FROM `sys_agents_tools` WHERE `active` = 1 AND `type` IN (" . $this->implode_escape($aTypes) . ") ORDER BY `id` ASC");
+        $aOut = [];
+        foreach ($aRows as $aRow) {
+            $aOut[] = [
+                'id' => (int)$aRow['id'],
+                'type' => (string)$aRow['type'],
+            ];
+        }
+
+        return $aOut;
+    }
+
+    /**
+     * Every tool id of these types, including inactive copies.
+     *
+     * @return int[]
+     */
+    public function getToolIdsByTypes(array $aTypes): array
+    {
+        if (!$aTypes)
+            return [];
+
+        $aRows = $this->getAll("SELECT `id` FROM `sys_agents_tools` WHERE `type` IN (" . $this->implode_escape($aTypes) . ")");
+        $aIds = [];
+        foreach ($aRows as $aRow)
+            $aIds[] = (int)$aRow['id'];
+
+        return $aIds;
     }
     }
 
 
     public function wipeAgentChatHistory($aAgent) 
     public function wipeAgentChatHistory($aAgent) 

+ 7 - 3
studio/template/scripts/BxBaseStudioAgentsAgents.php

@@ -139,7 +139,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
             $sTrigger = $oForm->getCleanValue('trigger');
             $sTrigger = $oForm->getCleanValue('trigger');
 
 
             $sTools = is_array($oForm->getCleanValue('tools')) ? implode(',', $oForm->getCleanValue('tools')) : '';
             $sTools = is_array($oForm->getCleanValue('tools')) ? implode(',', $oForm->getCleanValue('tools')) : '';
-            $aValsToAdd['tools'] = $sTools;
+            $aValsToAdd['tools'] = BxDolAiAgentTools::stripCsv($sTools);
             $aValsToAdd['acl_levels'] = (int)$oForm->getCleanValue('acl_levels');
             $aValsToAdd['acl_levels'] = (int)$oForm->getCleanValue('acl_levels');
 
 
             $bIsValid = true;
             $bIsValid = true;
@@ -208,7 +208,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
             $sTrigger = $oForm->getCleanValue('trigger');
             $sTrigger = $oForm->getCleanValue('trigger');
 
 
             $sTools = is_array($oForm->getCleanValue('tools')) ? implode(',', $oForm->getCleanValue('tools')) : '';
             $sTools = is_array($oForm->getCleanValue('tools')) ? implode(',', $oForm->getCleanValue('tools')) : '';
-            $aValsToAdd['tools'] = $sTools;
+            $aValsToAdd['tools'] = BxDolAiAgentTools::stripCsv($sTools);
             $aValsToAdd['acl_levels'] = (int)$oForm->getCleanValue('acl_levels');
             $aValsToAdd['acl_levels'] = (int)$oForm->getCleanValue('acl_levels');
 
 
             $bIsValid = true;
             $bIsValid = true;
@@ -1159,10 +1159,14 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
             $aValues[$k] = $v;
             $aValues[$k] = $v;
         }
         }
 
 
+        $aHidden = $sField === 'tools' ? array_flip(BxDolAiAgentTools::hiddenIds()) : [];
+
         $aTmplVars = [];
         $aTmplVars = [];
         if(!empty($aAgent[$sField])) {
         if(!empty($aAgent[$sField])) {
             $a = explode(',', $aAgent[$sField]);
             $a = explode(',', $aAgent[$sField]);
             foreach($a as $v) {
             foreach($a as $v) {
+                if (isset($aHidden[(int)$v]))
+                    continue;
                 $aInputSelect = [
                 $aInputSelect = [
                     'type' => 'select',
                     'type' => 'select',
                     'name' => $sField . '[]',
                     'name' => $sField . '[]',
@@ -1184,7 +1188,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
                 $aTmplVars[] = ['js_object' => $sJsObject, 'input_select' => $sInput];
                 $aTmplVars[] = ['js_object' => $sJsObject, 'input_select' => $sInput];
             }
             }
         }
         }
-        else  {
+        if (!$aTmplVars) {
             $aInputSelect = [
             $aInputSelect = [
                 'type' => 'select',
                 'type' => 'select',
                 'name' => $sField . '[]',
                 'name' => $sField . '[]',

+ 2 - 0
studio/template/scripts/BxBaseStudioAgentsTools.php

@@ -17,6 +17,8 @@ class BxBaseStudioAgentsTools extends BxDolStudioAgentsInstruments
         parent::__construct ($aOptions, $oTemplate);
         parent::__construct ($aOptions, $oTemplate);
 
 
         $this->_sUrlPage = BX_DOL_URL_STUDIO . 'agents.php?page=tools';
         $this->_sUrlPage = BX_DOL_URL_STUDIO . 'agents.php?page=tools';
+        if (!empty($this->_aOptions['source']) && is_string($this->_aOptions['source']))
+            $this->_aOptions['source'] = BxDolAiAgentTools::excludeHiddenFromSource($this->_aOptions['source']);
     }
     }
     
     
     public function performActionDuplicate()
     public function performActionDuplicate()

+ 137 - 0
tests/units/inc/classes/BxDolAiAgentToolsTest.php

@@ -0,0 +1,137 @@
+<?php
+
+/**
+ * Locked tools are attached in code. The picker and a saved CSV cannot grant them.
+ */
+class BxDolAiAgentToolsTest extends \PHPUnit\Framework\TestCase
+{
+    /** @var callable|null */
+    protected $handler;
+
+    protected function setUp(): void
+    {
+        $this->handler = set_exception_handler(static function () {
+        });
+        restore_exception_handler();
+    }
+
+    protected function tearDown(): void
+    {
+        for ($i = 0; $i < 5; $i++) {
+            $current = set_exception_handler(static function () {
+            });
+            restore_exception_handler();
+            if ($current === $this->handler)
+                return;
+            restore_exception_handler();
+        }
+    }
+
+    public function testTypesForNormalAgentAreChatOnly()
+    {
+        $this->assertSame(
+            ['chat_buttons', 'chat_title'],
+            BxDolAiAgentTools::typesFor(['name' => 'site_helper'])
+        );
+    }
+
+    public function testTypesForOperatorIncludesReadToolsThatStayVisible()
+    {
+        $this->assertSame([
+            'chat_buttons',
+            'chat_title',
+            'agent_create',
+            'mockup_upsert',
+            'mysql_write_safe',
+            'mysql_undo',
+            'mysql_schema',
+            'mysql_select',
+        ], BxDolAiAgentTools::typesFor(['name' => 'una_operator']));
+    }
+
+    public function testPickerOmitsLockedTypes()
+    {
+        $oDb = BxDolDb::getInstance();
+        $aLocked = $oDb->getAll("SELECT `id`, `type` FROM `sys_agents_tools` WHERE `type` IN (" . $oDb->implode_escape(BxDolAiAgentTools::hiddenTypes()) . ")");
+        $this->assertNotEmpty($aLocked);
+
+        $aHidden = [];
+        foreach ($aLocked as $aRow)
+            $aHidden[(int)$aRow['id']] = $aRow['type'];
+
+        $aTools = (new BxDolAiQuery())->getTools();
+        foreach (array_keys($aTools) as $iId)
+            $this->assertArrayNotHasKey((int)$iId, $aHidden);
+
+        $iContent = (int)$oDb->getOne("SELECT `id` FROM `sys_agents_tools` WHERE `type` = 'content_get' AND `active` = 1 ORDER BY `id` LIMIT 1");
+        $this->assertArrayHasKey($iContent, $aTools);
+
+        foreach (['mysql_schema', 'mysql_select'] as $sType) {
+            $iId = $this->toolId($sType);
+            $this->assertGreaterThan(0, $iId);
+            $this->assertArrayHasKey($iId, $aTools);
+        }
+    }
+
+    public function testToolsGridSourceOmitsHiddenTypes()
+    {
+        $sSource = BxDolAiAgentTools::excludeHiddenFromSource('SELECT * FROM `sys_agents_tools` WHERE 1 ');
+
+        $this->assertStringStartsWith('SELECT * FROM `sys_agents_tools` WHERE 1 ', $sSource);
+        foreach (BxDolAiAgentTools::hiddenTypes() as $sType)
+            $this->assertStringContainsString("'" . $sType . "'", $sSource);
+        $this->assertStringNotContainsString("'mysql_schema'", $sSource);
+        $this->assertStringNotContainsString("'mysql_select'", $sSource);
+        $this->assertStringNotContainsString("'mysql_write'", $sSource);
+    }
+
+    public function testStripCsvDropsLockedIdAndKeepsContentGet()
+    {
+        $oDb = BxDolDb::getInstance();
+        $iChat = (int)$oDb->getOne("SELECT `id` FROM `sys_agents_tools` WHERE `type` = 'chat_buttons' ORDER BY `id` LIMIT 1");
+        $iContent = (int)$oDb->getOne("SELECT `id` FROM `sys_agents_tools` WHERE `type` = 'content_get' ORDER BY `id` LIMIT 1");
+        $this->assertGreaterThan(0, $iChat);
+        $this->assertGreaterThan(0, $iContent);
+
+        $this->assertSame((string)$iContent, BxDolAiAgentTools::stripCsv($iChat . ',' . $iContent));
+    }
+
+    public function testResolveIdsIgnoresStoredLockedTools()
+    {
+        $oDb = BxDolDb::getInstance();
+        $iMysql = (int)$oDb->getOne("SELECT `id` FROM `sys_agents_tools` WHERE `type` = 'mysql_select' AND `active` = 1 ORDER BY `id` LIMIT 1");
+        $iContent = (int)$oDb->getOne("SELECT `id` FROM `sys_agents_tools` WHERE `type` = 'content_get' AND `active` = 1 ORDER BY `id` LIMIT 1");
+        $iWrite = (int)$oDb->getOne("SELECT `id` FROM `sys_agents_tools` WHERE `type` = 'mysql_write' ORDER BY `id` LIMIT 1");
+        $this->assertGreaterThan(0, $iMysql);
+        $this->assertGreaterThan(0, $iContent);
+
+        $iWriteSafe = $this->toolId('mysql_write_safe');
+        $iUndo = $this->toolId('mysql_undo');
+        $aNormal = BxDolAiAgentTools::resolveIds([
+            'name' => 'site_helper',
+            'tools' => $iMysql . ',' . $iContent . ',' . $iWriteSafe . ',' . $iUndo,
+        ]);
+        $this->assertContains($iContent, $aNormal);
+        $this->assertContains($iMysql, $aNormal);
+        $this->assertNotContains($iWriteSafe, $aNormal);
+        $this->assertNotContains($iUndo, $aNormal);
+        $this->assertContains($this->toolId('chat_buttons'), $aNormal);
+        $this->assertContains($this->toolId('chat_title'), $aNormal);
+        $this->assertNotContains($this->toolId('mysql_schema'), $aNormal);
+
+        $aOperator = BxDolAiAgentTools::resolveIds(['name' => 'una_operator', 'tools' => '']);
+        foreach (['chat_buttons', 'chat_title', 'agent_create', 'mockup_upsert', 'mysql_write_safe', 'mysql_undo', 'mysql_schema', 'mysql_select'] as $sType)
+            $this->assertContains($this->toolId($sType), $aOperator);
+        $this->assertContains($iMysql, $aOperator);
+        if ($iWrite > 0)
+            $this->assertNotContains($iWrite, $aOperator);
+    }
+
+    protected function toolId(string $sType): int
+    {
+        return (int)BxDolDb::getInstance()->getOne(
+            "SELECT `id` FROM `sys_agents_tools` WHERE `type` = :type AND `active` = 1 ORDER BY `id` LIMIT 1",
+            ['type' => $sType]
+        );
+    }
+}