BxDolAIToolAgentCreate.php 52 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111
  1. <?php defined('BX_DOL') or die('hack attempt');
  2. /**
  3. * Copyright (c) UNA, Inc - https://una.io
  4. * MIT License - https://opensource.org/licenses/MIT
  5. *
  6. * Operator tool: create / update / catalog Studio agents (sys_agents_agents).
  7. * mysql_write_safe cannot touch that table — use this instead.
  8. *
  9. * @defgroup UnaCore UNA Core
  10. * @{
  11. */
  12. use NeuronAI\Tools\PropertyType;
  13. use NeuronAI\Tools\ToolProperty;
  14. class BxDolAIToolAgentCreate extends BxDolAITool
  15. {
  16. protected const TRIGGERS = ['alert', 'scheduler', 'webhook', 'manual', 'agent', 'message', 'form-input'];
  17. protected const PRESETS = ['comment_reply', 'manual', 'message', 'custom'];
  18. protected const COMMENT_ACTIONS = ['commentPost', 'comment_added', 'commentAdded', 'cmt_post'];
  19. protected const FORBIDDEN_TOOL_TYPES = ['mysql_write'];
  20. protected const CLIP = 20000;
  21. protected const PROFILE_NAME_MAX = 40;
  22. protected const VERSION = 4;
  23. /** @var int[]|null see _allowedAccounts() */
  24. protected $_aAllowedAccounts = null;
  25. /** @var array<string,string>|null lowercase label => sys_modules.name */
  26. protected $_aModuleNames = null;
  27. protected const COMMENT_TOOLS = ['comments_get', 'comments_add', 'comment_get', 'content_get'];
  28. protected const COMMENT_PROMPT_TOOLS = "You run on a UNA commentPost alert. The user message is JSON: object_id, sender_profile_id, extra (comment id/text/system).\nIf sender_profile_id or extra.author / extra.author_id equals this agent's profile_id, do nothing — never reply to yourself (prevents loops).\nRead context with comments_get / content_get. Post at most one reply with comments_add on the same object. Then stop.\nMatch the comment language. Do not mention that you are an agent unless asked.";
  29. public function __construct()
  30. {
  31. parent::__construct(
  32. 'agent_create',
  33. 'Create or update a Studio agent (sys_agents_agents). Do NOT use mysql_write on that table. '
  34. . 'Never ask the user for a numeric profile_id. action=catalog returns profiles and profile_buttons — call chat_buttons with exactly those labels. '
  35. . 'Existing: profile=display name or id. New bot: profile_new=Name (creates a Persons profile under the preset Robot account, sys_profile_bot). create without profile/profile_new is refused. '
  36. . 'preset=comment_reply for “reply to every comment”: trigger=alert, async=1, comments_* tools. '
  37. . 'Pass module as one name from installed content modules: ' . $this->getContentModules() . '. Or alert as unit:action (for example bx_forum:commentPost). '
  38. . 'Show the plan in the site language. Confirm with chat_buttons whose reply labels are exactly "' . _t('_Yes') . '" and "' . _t('_No') . '". Wait for ' . _t('_Yes') . ', then create. Prompt goes in prompt_system.'
  39. );
  40. }
  41. protected function properties(): array
  42. {
  43. return [
  44. new ToolProperty('action', PropertyType::STRING, 'catalog | create | update. catalog = list alerts/tools/models/agents. create = insert. update = change prompt/tools/alert/active of agent_id.', true),
  45. new ToolProperty('preset', PropertyType::STRING, 'comment_reply | manual | message | custom. comment_reply = alert agent that replies to comments.', false),
  46. new ToolProperty('title', PropertyType::STRING, 'Human title. Required for create.', false),
  47. new ToolProperty('name', PropertyType::STRING, 'Unique machine name. Auto from title if empty.', false),
  48. new ToolProperty('description', PropertyType::STRING, 'Operator notes.', false),
  49. new ToolProperty('prompt_system', PropertyType::STRING, 'System / role prompt. Required for create. This is what the new agent will follow.', false),
  50. new ToolProperty('prompt_steps', PropertyType::STRING, 'Optional steps prompt.', false),
  51. new ToolProperty('prompt_output', PropertyType::STRING, 'Optional output prompt.', false),
  52. new ToolProperty('prompt_tools', PropertyType::STRING, 'Optional extra tool rules. comment_reply appends loop-prevention rules.', false),
  53. new ToolProperty('trigger', PropertyType::STRING, 'alert | scheduler | webhook | manual | agent | message | form-input. Default from preset (comment_reply → alert).', false),
  54. new ToolProperty('alert', PropertyType::STRING, 'unit:action from sys_alerts_log, e.g. bx_forum:commentPost. Required for trigger=alert unless module is set.', false),
  55. new ToolProperty('module', PropertyType::STRING, 'Installed module: module from installed modules list.', false),
  56. new ToolProperty('tools', PropertyType::STRING, 'CSV of tool types or ids (comments_add,comments_get). comment_reply fills comments_* if empty.', false),
  57. new ToolProperty('model_id', PropertyType::INTEGER, 'Chat model id. Default = operator agent / sys_agents_model.', false),
  58. new ToolProperty('profile_id', PropertyType::INTEGER, 'Numeric profile id. Prefer profile (name) or profile_new. Do not ask the user for this number.', false),
  59. new ToolProperty('profile', PropertyType::STRING, 'Existing profile the agent acts as: display name, @uri, or id. From catalog.profiles. Do not ask the user for a number.', false),
  60. new ToolProperty('profile_new', PropertyType::STRING, 'Short display name for a NEW Persons profile, like a person name: 1–3 words, max 40 chars. Examples: Posts Bot. NEVER a prompt, sentence, or agent instructions.', false),
  61. new ToolProperty('vector_store_id', PropertyType::INTEGER, 'Knowledge store id. Default = operator / first active store / 0.', false),
  62. new ToolProperty('async', PropertyType::INTEGER, '1 = delayed (use for alert comment replies so the post is not blocked). Default 1 for alert.', false),
  63. new ToolProperty('active', PropertyType::INTEGER, '1 = run immediately. Default 1.', false),
  64. new ToolProperty('scheduler_cron', PropertyType::STRING, 'CRON string for trigger=scheduler.', false),
  65. new ToolProperty('webhook_key', PropertyType::STRING, 'Bearer key for trigger=webhook.', false),
  66. new ToolProperty('message_profile_id', PropertyType::INTEGER, 'Accepted sender for trigger=message. 0 = any.', false),
  67. new ToolProperty('form_object', PropertyType::STRING, 'Form object for trigger=form-input.', false),
  68. new ToolProperty('form_input', PropertyType::STRING, 'Form input name for trigger=form-input.', false),
  69. new ToolProperty('agent_id', PropertyType::INTEGER, 'Existing sys_agents_agents.id for update.', false),
  70. new ToolProperty('query', PropertyType::STRING, 'catalog filter: alert unit/action, tool type, agent name.', false),
  71. new ToolProperty('dry_run', PropertyType::BOOLEAN, 'If true, validate and return the row without writing.', false),
  72. ];
  73. }
  74. public function __invoke(
  75. $action = 'catalog',
  76. $preset = '',
  77. $title = '',
  78. $name = '',
  79. $description = '',
  80. $prompt_system = '',
  81. $prompt_steps = '',
  82. $prompt_output = '',
  83. $prompt_tools = '',
  84. $trigger = '',
  85. $alert = '',
  86. $module = '',
  87. $tools = '',
  88. $model_id = 0,
  89. $profile_id = 0,
  90. $profile = '',
  91. $profile_new = '',
  92. $vector_store_id = 0,
  93. $async = null,
  94. $active = 1,
  95. $scheduler_cron = '',
  96. $webhook_key = '',
  97. $message_profile_id = 0,
  98. $form_object = '',
  99. $form_input = '',
  100. $agent_id = 0,
  101. $query = '',
  102. $dry_run = false
  103. ): array {
  104. $sAction = strtolower(trim((string)$action));
  105. if ($sAction === 'list')
  106. $sAction = 'catalog';
  107. if (!in_array($sAction, ['catalog', 'create', 'update'], true))
  108. return ['ok' => 0, 'error' => 'action must be catalog, create, or update'];
  109. try {
  110. if ($sAction === 'catalog')
  111. return $this->_catalog((string)$query, (string)$preset, (string)$module);
  112. if ($sAction === 'update')
  113. return $this->_update((int)$agent_id, [
  114. 'title' => $title,
  115. 'description' => $description,
  116. 'prompt_system' => $prompt_system,
  117. 'prompt_steps' => $prompt_steps,
  118. 'prompt_output' => $prompt_output,
  119. 'prompt_tools' => $prompt_tools,
  120. 'tools' => $tools,
  121. 'alert' => $alert,
  122. 'module' => $module,
  123. 'async' => $async,
  124. 'active' => $active,
  125. 'preset' => $preset,
  126. 'profile' => $profile,
  127. 'profile_id' => $profile_id,
  128. 'profile_new' => $profile_new,
  129. ], $this->_isDry($dry_run));
  130. return $this->_create([
  131. 'preset' => $preset,
  132. 'title' => $title,
  133. 'name' => $name,
  134. 'description' => $description,
  135. 'prompt_system' => $prompt_system,
  136. 'prompt_steps' => $prompt_steps,
  137. 'prompt_output' => $prompt_output,
  138. 'prompt_tools' => $prompt_tools,
  139. 'trigger' => $trigger,
  140. 'alert' => $alert,
  141. 'module' => $module,
  142. 'tools' => $tools,
  143. 'model_id' => $model_id,
  144. 'profile_id' => $profile_id,
  145. 'vector_store_id' => $vector_store_id,
  146. 'async' => $async,
  147. 'active' => $active,
  148. 'scheduler_cron' => $scheduler_cron,
  149. 'webhook_key' => $webhook_key,
  150. 'message_profile_id' => $message_profile_id,
  151. 'form_object' => $form_object,
  152. 'form_input' => $form_input,
  153. 'profile' => $profile,
  154. 'profile_new' => $profile_new,
  155. ], $this->_isDry($dry_run));
  156. } catch (Throwable $o) {
  157. return ['ok' => 0, 'error' => $o->getMessage()];
  158. }
  159. }
  160. protected function _isDry($dry_run): bool
  161. {
  162. return $dry_run === true || $dry_run === 1 || $dry_run === '1' || $dry_run === 'true';
  163. }
  164. protected function _catalog(string $sQuery, string $sPreset, string $sModule): array
  165. {
  166. $oDb = BxDolDb::getInstance();
  167. $sQ = mb_strtolower(trim($sQuery));
  168. $sMod = $this->_resolveModule($sModule !== '' ? $sModule : $sQuery);
  169. $aAlerts = [];
  170. if ($oDb->isTableExists('sys_alerts_log')) {
  171. $aRows = $oDb->getAll("SELECT `unit`, `action`, `counter_24h` FROM `sys_alerts_log` ORDER BY `counter_24h` DESC, `unit`, `action` LIMIT 400");
  172. foreach ($aRows as $aRow) {
  173. $sKey = $aRow['unit'] . ':' . $aRow['action'];
  174. $sHay = mb_strtolower($sKey . ' ' . ($aRow['unit'] ?? '') . ' ' . ($aRow['action'] ?? ''));
  175. $bComment = in_array($aRow['action'], self::COMMENT_ACTIONS, true);
  176. if ($sQ !== '' && strpos($sHay, $sQ) === false && !($sMod && $aRow['unit'] === $sMod) && !($sPreset === 'comment_reply' && $bComment))
  177. continue;
  178. if ($sPreset === 'comment_reply' && $sQ === '' && $sModule === '' && !$bComment)
  179. continue;
  180. if ($sMod && $aRow['unit'] !== $sMod && strpos($sHay, $sQ) === false)
  181. continue;
  182. $aAlerts[] = [
  183. 'alert' => $sKey,
  184. 'fired_24h' => (int)$aRow['counter_24h'],
  185. 'desc' => method_exists('BxDolAiQuery', 'getAlertDesc') ? (string)BxDolAiQuery::getAlertDesc($sKey) : '',
  186. ];
  187. if (count($aAlerts) >= 40)
  188. break;
  189. }
  190. }
  191. $aTools = [];
  192. $aToolRows = $oDb->getAll("SELECT `id`, `type`, `title`, `active` FROM `sys_agents_tools` ORDER BY `type` ASC");
  193. foreach ($aToolRows as $aRow) {
  194. if (in_array($aRow['type'], BxDolAiAgentTools::hiddenTypes(), true))
  195. continue;
  196. $sHay = mb_strtolower($aRow['id'] . ' ' . $aRow['type'] . ' ' . $aRow['title']);
  197. if ($sQ !== '' && strpos($sHay, $sQ) === false && $sPreset !== 'comment_reply')
  198. continue;
  199. if ($sPreset === 'comment_reply' && $sQ === '' && !in_array($aRow['type'], self::COMMENT_TOOLS, true))
  200. continue;
  201. $aTools[] = [
  202. 'id' => (int)$aRow['id'],
  203. 'type' => $aRow['type'],
  204. 'title' => $aRow['title'],
  205. 'active' => (int)$aRow['active'],
  206. ];
  207. }
  208. $aModels = $oDb->getAll("SELECT `id`, `type`, `model`, `title`, `capabilities`, `active` FROM `sys_agents_models` WHERE `active` = 1 AND (`capabilities` LIKE '%chatllm%' OR `capabilities` LIKE '%chatvlm%') ORDER BY `title` ASC LIMIT 30");
  209. $aStores = $oDb->isTableExists('sys_agents_vector_store')
  210. ? $oDb->getAll("SELECT `id`, `title`, `type`, `active` FROM `sys_agents_vector_store` WHERE `active` = 1 ORDER BY `title` ASC LIMIT 20")
  211. : [];
  212. $aAgents = $oDb->getAll("SELECT `id`, `name`, `title`, `trigger`, `alert`, `active`, `profile_id`, `model_id` FROM `sys_agents_agents` ORDER BY `id` DESC LIMIT 30");
  213. if ($sQ !== '') {
  214. $aAgents = array_values(array_filter($aAgents, function ($aRow) use ($sQ) {
  215. $sHay = mb_strtolower(($aRow['name'] ?? '') . ' ' . ($aRow['title'] ?? '') . ' ' . ($aRow['alert'] ?? '') . ' ' . ($aRow['trigger'] ?? ''));
  216. return strpos($sHay, $sQ) !== false;
  217. }));
  218. }
  219. $aDefaults = $this->_defaults();
  220. $aProfiles = $this->_listProfiles('');
  221. $sSuggest = $this->_suggestProfileName($sPreset);
  222. return [
  223. 'ok' => 1,
  224. 'version' => self::VERSION,
  225. 'action' => 'catalog',
  226. 'profiles' => $aProfiles,
  227. 'profiles_count' => count($aProfiles),
  228. 'suggested_new_name' => $sSuggest,
  229. 'profile_buttons' => $this->_profileButtons($aProfiles, $sSuggest),
  230. 'profile_hint' => 'Call chat_buttons now with exactly profile_buttons as reply labels (one per line, no other text). Tap on a name → profile=<name>; tap on «' . _t('_sys_agents_profile_btn_new', $sSuggest) . '» → profile_new=' . $sSuggest . '. create without profile/profile_new is refused.',
  231. 'defaults' => $aDefaults,
  232. 'presets' => self::PRESETS,
  233. 'comment_reply_hint' => 'Pick an alert, example - bx_forum:commentPost (module=bx_forum), module bx_forum must be installed. Tools: comments_get,comments_add,comment_get,content_get. async=1.',
  234. 'alerts' => $aAlerts,
  235. 'tools' => $aTools,
  236. 'models' => $aModels,
  237. 'vector_stores' => $aStores,
  238. 'agents' => $aAgents,
  239. ];
  240. }
  241. protected function _create(array $aIn, bool $bDry): array
  242. {
  243. $sPreset = $this->_preset($aIn['preset'] ?? '');
  244. $sTitle = trim((string)($aIn['title'] ?? ''));
  245. if ($sTitle === '')
  246. return ['ok' => 0, 'error' => 'title is required'];
  247. $sPrompt = $this->_clip((string)($aIn['prompt_system'] ?? ''));
  248. if (trim($sPrompt) === '')
  249. return ['ok' => 0, 'error' => 'prompt_system is required — this is what the new agent will do'];
  250. $sTrigger = strtolower(trim((string)($aIn['trigger'] ?? '')));
  251. if ($sTrigger === '')
  252. $sTrigger = $this->_triggerForPreset($sPreset);
  253. if (!in_array($sTrigger, self::TRIGGERS, true))
  254. return ['ok' => 0, 'error' => 'unknown trigger. Use: ' . implode(', ', self::TRIGGERS)];
  255. $aDefaults = $this->_defaults();
  256. $iModel = (int)($aIn['model_id'] ?? 0) ?: (int)$aDefaults['model_id'];
  257. $sProfileIn = trim((string)($aIn['profile'] ?? ''));
  258. $sProfileNewIn = trim((string)($aIn['profile_new'] ?? ''));
  259. if ($sProfileIn === '' && $sProfileNewIn === '' && (int)($aIn['profile_id'] ?? 0) <= 0) {
  260. // never fall back to the operator's own profile silently — the user must pick a name or a new one
  261. $aProfiles = $this->_listProfiles('');
  262. $sSuggest = $this->_suggestProfileName($sPreset);
  263. return [
  264. 'ok' => 0,
  265. 'error' => _t('_sys_agents_profile_required_err', _t('_sys_agents_profile_btn_new', $sSuggest)),
  266. 'profiles' => $aProfiles,
  267. 'suggested_new_name' => $sSuggest,
  268. 'profile_buttons' => $this->_profileButtons($aProfiles, $sSuggest),
  269. ];
  270. }
  271. $aProf = $this->_resolveProfile($sProfileIn, $aIn['profile_id'] ?? 0, $sProfileNewIn, $bDry);
  272. if (!empty($aProf['error']))
  273. return ['ok' => 0, 'error' => $aProf['error'], 'candidates' => $aProf['candidates'] ?? []];
  274. $iProfile = (int)($aProf['profile_id'] ?? 0);
  275. $iStore = (int)($aIn['vector_store_id'] ?? 0) ?: (int)$aDefaults['vector_store_id'];
  276. if ($iModel <= 0)
  277. return ['ok' => 0, 'error' => 'no chat model. Pass model_id or activate a chatllm model.'];
  278. if ($iProfile <= 0 && empty($aProf['dry_create']))
  279. return ['ok' => 0, 'error' => 'profile could not be resolved. Pass profile=<name from catalog.profiles> or profile_new=<name>.'];
  280. $oDb = BxDolDb::getInstance();
  281. if (!$oDb->getRow("SELECT `id` FROM `sys_agents_models` WHERE `id` = :id AND `active` = 1 LIMIT 1", ['id' => $iModel]))
  282. return ['ok' => 0, 'error' => "model_id {$iModel} is missing or inactive"];
  283. $sAlert = '';
  284. $aAlertPick = null;
  285. if ($sTrigger === 'alert') {
  286. $aAlertPick = $this->_resolveAlert((string)($aIn['alert'] ?? ''), (string)($aIn['module'] ?? ''), $sPreset);
  287. if (!empty($aAlertPick['error']))
  288. return ['ok' => 0, 'error' => $aAlertPick['error'], 'candidates' => $aAlertPick['candidates'] ?? []];
  289. $sAlert = $aAlertPick['alert'];
  290. }
  291. $sToolsCsv = $this->_resolveToolsCsv((string)($aIn['tools'] ?? ''), $sPreset);
  292. if (is_array($sToolsCsv) && isset($sToolsCsv['error']))
  293. return $sToolsCsv;
  294. if ($sTrigger === 'message') {
  295. $aDup = $oDb->getRow("SELECT `id`, `name` FROM `sys_agents_agents` WHERE `trigger` = 'message' AND `profile_id` = :p LIMIT 1", ['p' => $iProfile]);
  296. if ($aDup)
  297. return ['ok' => 0, 'error' => "profile_id {$iProfile} already has message-agent {$aDup['id']} ({$aDup['name']}). One message-agent per profile."];
  298. }
  299. if ($sTrigger === 'scheduler' && trim((string)($aIn['scheduler_cron'] ?? '')) === '')
  300. return ['ok' => 0, 'error' => 'scheduler_cron is required for trigger=scheduler'];
  301. if ($sTrigger === 'webhook' && trim((string)($aIn['webhook_key'] ?? '')) === '')
  302. return ['ok' => 0, 'error' => 'webhook_key is required for trigger=webhook'];
  303. if ($sTrigger === 'form-input' && (trim((string)($aIn['form_object'] ?? '')) === '' || trim((string)($aIn['form_input'] ?? '')) === ''))
  304. return ['ok' => 0, 'error' => 'form_object and form_input are required for trigger=form-input'];
  305. $iAsync = $aIn['async'];
  306. if ($iAsync === null || $iAsync === '')
  307. $iAsync = in_array($sTrigger, ['alert', 'webhook', 'message'], true) ? 1 : 0;
  308. $iAsync = (int)$iAsync ? 1 : 0;
  309. if (in_array($sTrigger, ['manual', 'scheduler'], true))
  310. $iAsync = 0;
  311. $iActive = isset($aIn['active']) && $aIn['active'] !== '' && $aIn['active'] !== null
  312. ? ((int)$aIn['active'] ? 1 : 0)
  313. : 1;
  314. $sName = $this->_uniqueName((string)($aIn['name'] ?? ''), $sTitle);
  315. $sPromptTools = $this->_clip((string)($aIn['prompt_tools'] ?? ''));
  316. if ($sPreset === 'comment_reply') {
  317. $sPromptTools = trim($sPromptTools . "\n\n" . self::COMMENT_PROMPT_TOOLS);
  318. }
  319. $sPromptSteps = $this->_clip((string)($aIn['prompt_steps'] ?? ''));
  320. if ($sPreset === 'comment_reply' && $sPromptSteps === '') {
  321. $sPromptSteps = "1. Parse the alert JSON.\n2. Skip if the comment author is this agent's profile_id.\n3. Read the parent object and recent comments.\n4. Write one reply with comments_add.\n5. Stop. Do not loop.";
  322. }
  323. $aRow = [
  324. 'title' => $this->_clip($sTitle, 255),
  325. 'name' => $sName,
  326. 'description' => $this->_clip((string)($aIn['description'] ?? ''), 2000),
  327. 'icon' => '',
  328. 'model_id' => $iModel,
  329. 'profile_id' => $iProfile,
  330. 'acl_levels' => 0,
  331. 'trigger' => $sTrigger,
  332. 'async' => $iAsync,
  333. 'alert' => $sAlert,
  334. 'scheduler_cron' => trim((string)($aIn['scheduler_cron'] ?? '')),
  335. 'webhook_key' => trim((string)($aIn['webhook_key'] ?? '')),
  336. 'message_profile_id' => (int)($aIn['message_profile_id'] ?? 0),
  337. 'form_object' => trim((string)($aIn['form_object'] ?? '')),
  338. 'form_input' => trim((string)($aIn['form_input'] ?? '')),
  339. 'max_turns' => 40,
  340. 'chat_ttl_min' => 0,
  341. 'max_input_chars' => 2000,
  342. 'max_tokens' => 1024,
  343. 'max_sessions_per_hour' => 3,
  344. 'max_sessions_per_day' => 10,
  345. 'vector_store_id' => $iStore,
  346. 'chat_history_context' => $sTrigger === 'alert' ? 0 : 50000,
  347. 'tools' => $sToolsCsv,
  348. 'tools_max_run' => $sPreset === 'comment_reply' ? 8 : 10,
  349. 'prompt_system' => $sPrompt,
  350. 'prompt_steps' => $sPromptSteps,
  351. 'prompt_output' => $this->_clip((string)($aIn['prompt_output'] ?? '')),
  352. 'prompt_tools' => $sPromptTools,
  353. 'added' => time(),
  354. 'active' => $iActive,
  355. ];
  356. $aRow = $this->_onlyExistingColumns($oDb, 'sys_agents_agents', $aRow);
  357. $aOut = [
  358. 'ok' => 1,
  359. 'action' => $bDry ? 'dry_run' : 'create',
  360. 'preset' => $sPreset,
  361. 'studio' => '/studio/agents.php?page=agents',
  362. 'row' => [
  363. 'name' => $aRow['name'],
  364. 'title' => $aRow['title'],
  365. 'trigger' => $aRow['trigger'],
  366. 'alert' => $aRow['alert'] ?? '',
  367. 'async' => (int)($aRow['async'] ?? 0),
  368. 'active' => (int)$aRow['active'],
  369. 'model_id' => (int)$aRow['model_id'],
  370. 'profile_id' => (int)$aRow['profile_id'],
  371. 'profile_name' => $aProf['name'] ?? '',
  372. 'profile_created' => (int)($aProf['created'] ?? 0),
  373. 'vector_store_id' => (int)($aRow['vector_store_id'] ?? 0),
  374. 'tools' => $aRow['tools'],
  375. 'prompt_system_chars' => strlen($aRow['prompt_system']),
  376. ],
  377. ];
  378. if (!empty($aAlertPick['warning']))
  379. $aOut['warning'] = $aAlertPick['warning'];
  380. if ($bDry)
  381. return $aOut;
  382. $mixed = $oDb->query("INSERT INTO `sys_agents_agents` SET " . $oDb->arrayToSQL($aRow));
  383. if ($mixed === false)
  384. return ['ok' => 0, 'error' => 'INSERT failed'];
  385. $iId = (int)$oDb->lastId();
  386. $oDb->cleanCache('sys_agents_with_alert');
  387. $this->_logSql('insert', 'sys_agents_agents', 'id', (string)$iId, 'INSERT agent ' . $aRow['name'], null, $aRow);
  388. $aOut['id'] = $iId;
  389. $aOut['row']['id'] = $iId;
  390. $aOut['next'] = $iActive
  391. ? 'Agent is active. Test the event (post a comment) or open Studio Agents grid.'
  392. : 'Created inactive. action=update agent_id=' . $iId . ' active=1 to turn on.';
  393. return $aOut;
  394. }
  395. protected function _update(int $iId, array $aIn, bool $bDry): array
  396. {
  397. if ($iId <= 0)
  398. return ['ok' => 0, 'error' => 'agent_id is required for update'];
  399. $oDb = BxDolDb::getInstance();
  400. $aBefore = $oDb->getRow("SELECT * FROM `sys_agents_agents` WHERE `id` = :id LIMIT 1", ['id' => $iId]);
  401. if (!$aBefore)
  402. return ['ok' => 0, 'error' => "agent {$iId} not found"];
  403. $aSet = [];
  404. foreach (['title', 'description', 'prompt_system', 'prompt_steps', 'prompt_output', 'prompt_tools'] as $sF) {
  405. if (isset($aIn[$sF]) && $aIn[$sF] !== '' && $aIn[$sF] !== null)
  406. $aSet[$sF] = $this->_clip((string)$aIn[$sF], $sF === 'title' ? 255 : self::CLIP);
  407. }
  408. if (isset($aIn['tools']) && $aIn['tools'] !== '' && $aIn['tools'] !== null) {
  409. $sCsv = $this->_resolveToolsCsv((string)$aIn['tools'], $this->_preset($aIn['preset'] ?? ''));
  410. if (is_array($sCsv) && isset($sCsv['error']))
  411. return $sCsv;
  412. $aSet['tools'] = $sCsv;
  413. }
  414. if ((isset($aIn['alert']) && $aIn['alert'] !== '') || (isset($aIn['module']) && $aIn['module'] !== '')) {
  415. $aAlertPick = $this->_resolveAlert((string)($aIn['alert'] ?? ''), (string)($aIn['module'] ?? ''), $this->_preset($aIn['preset'] ?? 'comment_reply'));
  416. if (!empty($aAlertPick['error']))
  417. return ['ok' => 0, 'error' => $aAlertPick['error'], 'candidates' => $aAlertPick['candidates'] ?? []];
  418. $aSet['alert'] = $aAlertPick['alert'];
  419. $aSet['trigger'] = 'alert';
  420. }
  421. if ($aIn['async'] !== null && $aIn['async'] !== '')
  422. $aSet['async'] = (int)$aIn['async'] ? 1 : 0;
  423. if ($aIn['active'] !== null && $aIn['active'] !== '')
  424. $aSet['active'] = (int)$aIn['active'] ? 1 : 0;
  425. $bWantProfile = (isset($aIn['profile']) && $aIn['profile'] !== '' && $aIn['profile'] !== null)
  426. || (isset($aIn['profile_new']) && $aIn['profile_new'] !== '' && $aIn['profile_new'] !== null)
  427. || !empty($aIn['profile_id']);
  428. $aProf = ['name' => '', 'created' => 0];
  429. if ($bWantProfile) {
  430. $aProf = $this->_resolveProfile($aIn['profile'] ?? '', $aIn['profile_id'] ?? 0, $aIn['profile_new'] ?? '', $bDry);
  431. if (!empty($aProf['error']))
  432. return ['ok' => 0, 'error' => $aProf['error'], 'candidates' => $aProf['candidates'] ?? []];
  433. if (!empty($aProf['profile_id']))
  434. $aSet['profile_id'] = (int)$aProf['profile_id'];
  435. }
  436. if (!$aSet)
  437. return ['ok' => 0, 'error' => 'nothing to update'];
  438. $aSet = $this->_onlyExistingColumns($oDb, 'sys_agents_agents', $aSet);
  439. if ($bDry) {
  440. return ['ok' => 1, 'action' => 'dry_run', 'id' => $iId, 'set' => array_keys($aSet)];
  441. }
  442. $b = $oDb->query("UPDATE `sys_agents_agents` SET " . $oDb->arrayToSQL($aSet) . " WHERE `id` = :id", ['id' => $iId]);
  443. if ($b === false)
  444. return ['ok' => 0, 'error' => 'UPDATE failed'];
  445. $oDb->cleanCache('sys_agents_with_alert');
  446. $aAfter = $oDb->getRow("SELECT * FROM `sys_agents_agents` WHERE `id` = :id LIMIT 1", ['id' => $iId]);
  447. $this->_logSql('update', 'sys_agents_agents', 'id', (string)$iId, 'UPDATE agent ' . $iId, $aBefore, $aAfter ?: null);
  448. return [
  449. 'ok' => 1,
  450. 'action' => 'update',
  451. 'id' => $iId,
  452. 'name' => $aAfter['name'] ?? $aBefore['name'],
  453. 'changed' => array_keys($aSet),
  454. 'active' => (int)($aAfter['active'] ?? 0),
  455. 'profile_id' => (int)($aAfter['profile_id'] ?? $aBefore['profile_id'] ?? 0),
  456. 'profile_name' => $aProf['name'] ?? '',
  457. 'profile_created' => (int)($aProf['created'] ?? 0),
  458. 'studio' => '/studio/agents.php?page=agents',
  459. ];
  460. }
  461. protected function _preset($s): string
  462. {
  463. $s = strtolower(trim((string)$s));
  464. if ($s === '' || $s === 'custom')
  465. return 'custom';
  466. if (in_array($s, self::PRESETS, true))
  467. return $s;
  468. return 'custom';
  469. }
  470. protected function _triggerForPreset(string $sPreset): string
  471. {
  472. if ($sPreset === 'comment_reply')
  473. return 'alert';
  474. if ($sPreset === 'message')
  475. return 'message';
  476. if ($sPreset === 'manual')
  477. return 'manual';
  478. return 'alert';
  479. }
  480. protected function _resolveModule(string $s): string
  481. {
  482. $s = trim($s);
  483. if ($s === '')
  484. return '';
  485. $sLow = mb_strtolower($s);
  486. $aNames = $this->_moduleNames();
  487. if (isset($aNames[$sLow]))
  488. return $aNames[$sLow];
  489. if (preg_match('/^[a-z0-9_]+$/i', $s))
  490. return $s;
  491. return '';
  492. }
  493. /**
  494. * Labels that mean a module: machine name, uri, install title, and the current-language title from _t().
  495. * Enabled modules claim a label before disabled ones. A name or uri is never overwritten by a title.
  496. *
  497. * @return array<string,string>
  498. */
  499. protected function _moduleNames(): array
  500. {
  501. if ($this->_aModuleNames !== null)
  502. return $this->_aModuleNames;
  503. $this->_aModuleNames = [];
  504. $oDb = BxDolDb::getInstance();
  505. $aRows = $oDb->getAll("SELECT `name`, `uri`, `title` FROM `sys_modules` WHERE `name` <> '' ORDER BY `enabled` DESC, `name` ASC");
  506. foreach ($aRows as $aRow)
  507. $this->_rememberModule((string)$aRow['name'], (string)$aRow['name']);
  508. foreach ($aRows as $aRow)
  509. $this->_rememberModule((string)$aRow['uri'], (string)$aRow['name']);
  510. foreach ($aRows as $aRow) {
  511. $sName = (string)$aRow['name'];
  512. $this->_rememberModule((string)$aRow['title'], $sName);
  513. $sKey = BxDolModule::getTitleKey((string)$aRow['uri']);
  514. $sTitle = _t($sKey);
  515. if ($sTitle !== $sKey)
  516. $this->_rememberModule($sTitle, $sName);
  517. }
  518. return $this->_aModuleNames;
  519. }
  520. protected function _rememberModule(string $sLabel, string $sName): void
  521. {
  522. $sLabel = mb_strtolower(trim($sLabel));
  523. if ($sLabel === '' || isset($this->_aModuleNames[$sLabel]))
  524. return;
  525. $this->_aModuleNames[$sLabel] = $sName;
  526. }
  527. protected function _resolveAlert(string $sAlert, string $sModule, string $sPreset): array
  528. {
  529. $sAlert = trim($sAlert);
  530. $sAlert = str_replace([' ', '/', '|'], ':', $sAlert);
  531. $sAlert = preg_replace('/:+/', ':', $sAlert);
  532. if ($sAlert !== '' && preg_match('/^[a-zA-Z0-9_]+:[a-zA-Z0-9_]+$/', $sAlert)) {
  533. $aWarn = $this->_alertInLog($sAlert) ? '' : 'Alert is not in sys_alerts_log yet (never fired). It will run once that unit:action happens.';
  534. return ['alert' => $sAlert, 'warning' => $aWarn];
  535. }
  536. $sMod = $this->_resolveModule($sModule !== '' ? $sModule : $sAlert);
  537. $aCandidates = $this->_commentAlerts($sMod);
  538. if ($sPreset === 'comment_reply' || $sMod !== '') {
  539. if (count($aCandidates) === 1)
  540. return ['alert' => $aCandidates[0]['alert']];
  541. if (count($aCandidates) > 1)
  542. return ['error' => 'Several comment alerts match. Pass alert=unit:action.', 'candidates' => $aCandidates];
  543. if ($sMod !== '')
  544. return ['error' => "No comment alert for module {$sMod} in sys_alerts_log. Someone must post a comment once, then retry, or pass alert explicitly (e.g. {$sMod}:commentPost).", 'candidates' => []];
  545. }
  546. return ['error' => 'Pass alert as unit:action (bx_forum:commentPost) or module=discussions / bx_forum. action=catalog preset=comment_reply to list.', 'candidates' => $aCandidates];
  547. }
  548. protected function _commentAlerts(string $sMod): array
  549. {
  550. $oDb = BxDolDb::getInstance();
  551. if (!$oDb->isTableExists('sys_alerts_log'))
  552. return [];
  553. $aParams = [];
  554. $sWhere = "`action` IN (" . $oDb->implode_escape(self::COMMENT_ACTIONS) . ")";
  555. if ($sMod !== '') {
  556. $sWhere .= " AND `unit` = :u";
  557. $aParams['u'] = $sMod;
  558. }
  559. $aRows = $oDb->getAll("SELECT `unit`, `action`, `counter_24h` FROM `sys_alerts_log` WHERE {$sWhere} ORDER BY `counter_24h` DESC LIMIT 20", $aParams);
  560. $aOut = [];
  561. foreach ($aRows as $aRow) {
  562. $aOut[] = [
  563. 'alert' => $aRow['unit'] . ':' . $aRow['action'],
  564. 'fired_24h' => (int)$aRow['counter_24h'],
  565. ];
  566. }
  567. return $aOut;
  568. }
  569. protected function _alertInLog(string $sAlert): bool
  570. {
  571. $oDb = BxDolDb::getInstance();
  572. if (!$oDb->isTableExists('sys_alerts_log'))
  573. return false;
  574. $a = explode(':', $sAlert, 2);
  575. $s = $oDb->getOne("SELECT CONCAT(`unit`, ':', `action`) FROM `sys_alerts_log` WHERE `unit` = :u AND `action` = :a LIMIT 1", [
  576. 'u' => $a[0] ?? '',
  577. 'a' => $a[1] ?? '',
  578. ]);
  579. return (bool)$s;
  580. }
  581. protected function _listProfiles(string $sQuery = ''): array
  582. {
  583. $aOut = [];
  584. $aSeen = [];
  585. $oDb = BxDolDb::getInstance();
  586. // each source is isolated: one failing query must not empty the whole list
  587. $aDefaults = $this->_defaults();
  588. $this->_pushProfile($aOut, $aSeen, (int)$aDefaults['profile_id'], '', 'default');
  589. try {
  590. // profiles of the preset Robot account (sys_profile_bot) go first — new profiles are created there
  591. $iBotAccount = $this->_botAccount();
  592. if ($iBotAccount > 0) {
  593. $aRows = $oDb->getAll("SELECT `id` FROM `sys_profiles` WHERE `account_id` = :a AND `status` = 'active' ORDER BY `id` ASC LIMIT 50", ['a' => $iBotAccount]);
  594. foreach ($aRows as $aRow)
  595. $this->_pushProfile($aOut, $aSeen, (int)$aRow['id'], '', 'robot');
  596. }
  597. } catch (Throwable $o) {
  598. bx_log('sys_agents', 'agent_create profiles (robot account): ' . $o->getMessage(), BX_LOG_WARN);
  599. }
  600. try {
  601. $iOpAccount = (int)$oDb->getOne("SELECT `account_id` FROM `sys_profiles` WHERE `id` = :id LIMIT 1", ['id' => (int)$aDefaults['profile_id']]);
  602. if ($iOpAccount > 0) {
  603. $aRows = $oDb->getAll("SELECT `id` FROM `sys_profiles` WHERE `account_id` = :a AND `status` = 'active' ORDER BY `id` ASC LIMIT 50", ['a' => $iOpAccount]);
  604. foreach ($aRows as $aRow)
  605. $this->_pushProfile($aOut, $aSeen, (int)$aRow['id'], '', 'operator');
  606. }
  607. } catch (Throwable $o) {
  608. bx_log('sys_agents', 'agent_create profiles (account): ' . $o->getMessage(), BX_LOG_WARN);
  609. }
  610. try {
  611. if (function_exists('bx_srv')) {
  612. $aOpts = bx_srv('system', 'get_options_agents_profile', [false], 'TemplServices');
  613. if (is_array($aOpts)) {
  614. foreach ($aOpts as $aOpt)
  615. $this->_pushProfile($aOut, $aSeen, (int)($aOpt['key'] ?? 0), (string)($aOpt['value'] ?? ''), 'operator');
  616. }
  617. }
  618. } catch (Throwable $o) {
  619. bx_log('sys_agents', 'agent_create profiles (service): ' . $o->getMessage(), BX_LOG_WARN);
  620. }
  621. try {
  622. $iRoleAdmin = defined('BX_DOL_ROLE_ADMIN') ? (int)BX_DOL_ROLE_ADMIN : 2;
  623. $aRows = $oDb->getAll("SELECT p.`id` FROM `sys_profiles` p INNER JOIN `sys_accounts` a ON a.`id` = p.`account_id` WHERE (a.`role` & :r) AND p.`status` = 'active' ORDER BY p.`id` ASC LIMIT 50", [
  624. 'r' => $iRoleAdmin,
  625. ]);
  626. foreach ($aRows as $aRow)
  627. $this->_pushProfile($aOut, $aSeen, (int)$aRow['id'], '', 'operator');
  628. } catch (Throwable $o) {
  629. bx_log('sys_agents', 'agent_create profiles (admins): ' . $o->getMessage(), BX_LOG_WARN);
  630. }
  631. try {
  632. $aUsed = $oDb->getColumn("SELECT DISTINCT `profile_id` FROM `sys_agents_agents` WHERE `profile_id` > 0 ORDER BY `profile_id` DESC LIMIT 30");
  633. foreach ($aUsed as $iId)
  634. $this->_pushProfile($aOut, $aSeen, (int)$iId, '', 'agent');
  635. } catch (Throwable $o) {
  636. bx_log('sys_agents', 'agent_create profiles (agents): ' . $o->getMessage(), BX_LOG_WARN);
  637. }
  638. $sQ = trim($sQuery);
  639. if ($sQ !== '' && mb_strlen($sQ) <= 80 && $oDb->isTableExists('bx_persons_data')) {
  640. $aRows = $oDb->getAll("SELECT p.`id`, d.`fullname` FROM `sys_profiles` p INNER JOIN `bx_persons_data` d ON d.`id` = p.`content_id` AND p.`type` = 'bx_persons' WHERE d.`fullname` LIKE :q AND p.`status` = 'active'" . $this->_allowedAccountsSql() . " ORDER BY d.`fullname` ASC LIMIT 20", [
  641. 'q' => '%' . $sQ . '%',
  642. ]);
  643. foreach ($aRows as $aRow)
  644. $this->_pushProfile($aOut, $aSeen, (int)$aRow['id'], (string)$aRow['fullname'], 'search');
  645. }
  646. return $aOut;
  647. }
  648. protected function _profileButtons(array $aProfiles, string $sSuggest): array
  649. {
  650. $aOut = [];
  651. foreach ($aProfiles as $aP) {
  652. $s = (string)($aP['name'] ?? '');
  653. if ($s !== '' && !in_array($s, $aOut, true))
  654. $aOut[] = $s;
  655. }
  656. $aOut[] = _t('_sys_agents_profile_btn_new', $sSuggest);
  657. return $aOut;
  658. }
  659. protected function _suggestProfileName(string $sPreset): string
  660. {
  661. return _t($this->_preset($sPreset) === 'comment_reply' ? '_sys_agents_profile_name_commenter' : '_sys_agents_profile_name_bot');
  662. }
  663. /**
  664. * Accounts whose profiles an agent may act as: the operators' (the profiles
  665. * Studio offers, get_options_agents_profile) and the Robot account's, where
  666. * profile_new creates them. Never a member's: an agent on a member's profile
  667. * would post, and as a message agent answer their messages, in their name.
  668. *
  669. * @return int[]
  670. */
  671. protected function _allowedAccounts(): array
  672. {
  673. if ($this->_aAllowedAccounts === null) {
  674. $aIds = array_map('intval', (array)BxDolAccountQuery::getInstance()->getOperators());
  675. $aIds[] = $this->_botAccount();
  676. $this->_aAllowedAccounts = array_values(array_unique(array_filter($aIds)));
  677. }
  678. return $this->_aAllowedAccounts;
  679. }
  680. protected function _isAllowedAgentProfile(int $iProfileId): bool
  681. {
  682. if ($iProfileId <= 0)
  683. return false;
  684. $iAccount = (int)BxDolDb::getInstance()->getOne("SELECT `account_id` FROM `sys_profiles` WHERE `id` = :id LIMIT 1", ['id' => $iProfileId]);
  685. return $iAccount > 0 && in_array($iAccount, $this->_allowedAccounts(), true);
  686. }
  687. /** `AND p.account_id IN (...)` for a query over `sys_profiles` aliased `p`. */
  688. protected function _allowedAccountsSql(): string
  689. {
  690. $aIds = $this->_allowedAccounts();
  691. return ' AND p.`account_id` IN (' . ($aIds ? implode(',', $aIds) : '0') . ')';
  692. }
  693. protected function _pushProfile(array &$aOut, array &$aSeen, int $iId, string $sFallback, string $sSource): void
  694. {
  695. if ($iId <= 0 || isset($aSeen[$iId]) || !$this->_isAllowedAgentProfile($iId))
  696. return;
  697. try {
  698. $sName = $this->_profileDisplayName($iId, $sFallback);
  699. } catch (Throwable $o) {
  700. $sName = $sFallback;
  701. }
  702. if ($sName === '')
  703. $sName = '#' . $iId;
  704. $aOut[] = ['id' => $iId, 'name' => $sName, 'source' => $sSource];
  705. $aSeen[$iId] = 1;
  706. }
  707. protected function _shortDisplayName(string $s): array
  708. {
  709. $s = trim(preg_replace('/\s+/u', ' ', $s));
  710. $sPrefix = trim(_t('_sys_agents_profile_btn_new', ''));
  711. if ($sPrefix !== '' && $sPrefix !== '_sys_agents_profile_btn_new')
  712. $s = trim((string)preg_replace('/^' . preg_quote($sPrefix, '/') . '\s*/iu', '', $s));
  713. if ($s === '')
  714. return ['error' => 'profile name is empty'];
  715. $iWords = count(preg_split('/\s+/u', $s, -1, PREG_SPLIT_NO_EMPTY));
  716. if (mb_strlen($s) > self::PROFILE_NAME_MAX || $iWords > 3 || preg_match('/[\r\n.!?—–;:]/u', $s)) {
  717. return ['error' => _t('_sys_agents_profile_new_err', _t('_sys_agents_profile_name_commenter'), _t('_sys_agents_profile_name_bot'))];
  718. }
  719. return ['name' => $s];
  720. }
  721. protected function _profileDisplayName(int $iId, string $sFallback = ''): string
  722. {
  723. $o = BxDolProfile::getInstance($iId);
  724. if ($o)
  725. return $o->getDisplayName();
  726. if ($sFallback !== '')
  727. return $sFallback;
  728. return '#' . $iId;
  729. }
  730. protected function _resolveProfile($sProfile, $mixedId, $sNew, bool $bDry): array
  731. {
  732. $sNew = trim((string)$sNew);
  733. if ($sNew !== '')
  734. return $this->_createPersonProfile($sNew, $bDry);
  735. $sProfile = trim((string)$sProfile);
  736. $iId = (int)$mixedId;
  737. if ($sProfile !== '' && preg_match('/^\d+$/', $sProfile))
  738. $iId = (int)$sProfile;
  739. if ($iId > 0) {
  740. $o = BxDolProfile::getInstance($iId);
  741. if (!$o)
  742. return ['error' => "profile {$iId} not found"];
  743. if (!$this->_isAllowedAgentProfile($iId))
  744. return ['error' => "profile {$iId} belongs to a member, and an agent may not act as a member. Pick one from catalog.profiles or pass profile_new=<name>."];
  745. return ['profile_id' => $iId, 'name' => $o->getDisplayName(), 'created' => 0];
  746. }
  747. if ($sProfile === '')
  748. return ['profile_id' => 0, 'name' => '', 'created' => 0];
  749. $sProfile = ltrim($sProfile, '@');
  750. $aHits = [];
  751. foreach ($this->_listProfiles($sProfile) as $aP) {
  752. if (mb_strtolower((string)$aP['name']) === mb_strtolower($sProfile) || (string)$aP['id'] === $sProfile)
  753. $aHits[(int)$aP['id']] = $aP;
  754. }
  755. $oDb = BxDolDb::getInstance();
  756. if ($oDb->isTableExists('bx_persons_data')) {
  757. $aRows = $oDb->getAll("SELECT p.`id`, d.`fullname` FROM `sys_profiles` p INNER JOIN `bx_persons_data` d ON d.`id` = p.`content_id` AND p.`type` = 'bx_persons' WHERE d.`fullname` = :n AND p.`status` = 'active'" . $this->_allowedAccountsSql() . " LIMIT 10", [
  758. 'n' => $sProfile,
  759. ]);
  760. foreach ($aRows as $aRow)
  761. $aHits[(int)$aRow['id']] = ['id' => (int)$aRow['id'], 'name' => $aRow['fullname']];
  762. if (!$aHits) {
  763. $aRows = $oDb->getAll("SELECT p.`id`, d.`fullname` FROM `sys_profiles` p INNER JOIN `bx_persons_data` d ON d.`id` = p.`content_id` AND p.`type` = 'bx_persons' WHERE d.`fullname` LIKE :n AND p.`status` = 'active'" . $this->_allowedAccountsSql() . " ORDER BY d.`fullname` ASC LIMIT 10", [
  764. 'n' => '%' . $sProfile . '%',
  765. ]);
  766. foreach ($aRows as $aRow)
  767. $aHits[(int)$aRow['id']] = ['id' => (int)$aRow['id'], 'name' => $aRow['fullname']];
  768. }
  769. }
  770. $aList = array_values($aHits);
  771. if (count($aList) === 1)
  772. return ['profile_id' => (int)$aList[0]['id'], 'name' => $aList[0]['name'], 'created' => 0];
  773. if (count($aList) > 1)
  774. return ['error' => 'Several profiles match. Pass profile=exact name from catalog.profiles.', 'candidates' => $aList];
  775. return ['error' => 'No profile named «' . $sProfile . '». Pick from catalog.profiles or pass profile_new=' . $sProfile . '.'];
  776. }
  777. protected function _createPersonProfile(string $sName, bool $bDry): array
  778. {
  779. $aName = $this->_shortDisplayName($sName);
  780. if (!empty($aName['error']))
  781. return $aName;
  782. $sName = $aName['name'];
  783. $oDb = BxDolDb::getInstance();
  784. if (!$oDb->isTableExists('bx_persons_data'))
  785. return ['error' => 'bx_persons_data is missing — cannot create a Persons profile'];
  786. // new bot profiles always live under the preset Robot account (sys_profile_bot), not under the operator.
  787. // author is that Robot profile's id; sys_profiles.account_id is the account that owns it.
  788. $aBot = $this->_botIdentity();
  789. $iAuthor = (int)$aBot['profile_id'];
  790. $iAccount = (int)$aBot['account_id'];
  791. if ($iAuthor <= 0 || $iAccount <= 0)
  792. return ['error' => 'Robot account not found: set sys_profile_bot (Studio → Settings → Bot profile), then retry.'];
  793. $aExist = $oDb->getRow("SELECT p.`id` FROM `sys_profiles` p INNER JOIN `bx_persons_data` d ON d.`id` = p.`content_id` AND p.`type` = 'bx_persons' WHERE p.`account_id` = :a AND d.`fullname` = :n LIMIT 1", [
  794. 'a' => $iAccount,
  795. 'n' => $sName,
  796. ]);
  797. if ($aExist)
  798. return ['profile_id' => (int)$aExist['id'], 'name' => $sName, 'created' => 0];
  799. if ($bDry)
  800. return ['profile_id' => 0, 'name' => $sName, 'created' => 1, 'dry_create' => 1];
  801. $iNow = time();
  802. $iPublic = defined('BX_DOL_PG_ALL') ? BX_DOL_PG_ALL : 3;
  803. $aPerson = $this->_onlyExistingColumns($oDb, 'bx_persons_data', [
  804. 'author' => $iAuthor,
  805. 'added' => $iNow,
  806. 'changed' => $iNow,
  807. 'fullname' => $sName,
  808. 'last_name' => '',
  809. 'allow_view_to' => $iPublic,
  810. 'allow_post_to' => $iPublic,
  811. 'allow_contact_to' => $iPublic,
  812. ]);
  813. $mixed = $oDb->query("INSERT INTO `bx_persons_data` SET " . $oDb->arrayToSQL($aPerson));
  814. if ($mixed === false)
  815. return ['error' => 'failed to insert bx_persons_data'];
  816. $iContent = (int)$oDb->lastId();
  817. $this->_logSql('insert', 'bx_persons_data', 'id', (string)$iContent, 'INSERT person ' . $sName, null, array_merge($aPerson, ['id' => $iContent]));
  818. $iAction = defined('BX_PROFILE_ACTION_AUTO') ? BX_PROFILE_ACTION_AUTO : 0;
  819. $iProfile = (int)BxDolProfile::add($iAction, $iAccount, $iContent, 'active', 'bx_persons');
  820. if ($iProfile <= 0)
  821. return ['error' => 'person row created but sys_profiles insert failed', 'content_id' => $iContent];
  822. $this->_logSql('insert', 'sys_profiles', 'id', (string)$iProfile, 'INSERT profile ' . $sName, null, [
  823. 'id' => $iProfile,
  824. 'account_id' => $iAccount,
  825. 'content_id' => $iContent,
  826. 'type' => 'bx_persons',
  827. 'status' => 'active',
  828. ]);
  829. return ['profile_id' => $iProfile, 'name' => $sName, 'created' => 1];
  830. }
  831. protected function _resolveToolsCsv(string $sTools, string $sPreset)
  832. {
  833. $oDb = BxDolDb::getInstance();
  834. $aWanted = [];
  835. $sTools = trim($sTools);
  836. if ($sTools !== '') {
  837. foreach (preg_split('/[,\s]+/', $sTools) as $sTok) {
  838. $sTok = trim($sTok);
  839. if ($sTok === '')
  840. continue;
  841. $aWanted[] = $sTok;
  842. }
  843. } else if ($sPreset === 'comment_reply') {
  844. $aWanted = self::COMMENT_TOOLS;
  845. }
  846. if (!$aWanted)
  847. return '';
  848. $aIds = [];
  849. $aMissing = [];
  850. foreach ($aWanted as $sTok) {
  851. if (preg_match('/^\d+$/', $sTok)) {
  852. $aRow = $oDb->getRow("SELECT `id`, `type`, `active` FROM `sys_agents_tools` WHERE `id` = :id LIMIT 1", ['id' => (int)$sTok]);
  853. } else {
  854. $aRow = $oDb->getRow("SELECT `id`, `type`, `active` FROM `sys_agents_tools` WHERE `type` = :t ORDER BY `active` DESC, `id` ASC LIMIT 1", ['t' => $sTok]);
  855. }
  856. if (!$aRow) {
  857. $aMissing[] = $sTok;
  858. continue;
  859. }
  860. if (in_array($aRow['type'], self::FORBIDDEN_TOOL_TYPES, true))
  861. return ['ok' => 0, 'error' => 'Refusing stock mysql_write on a created agent.'];
  862. if (in_array($aRow['type'], BxDolAiAgentTools::hiddenTypes(), true))
  863. continue;
  864. $aIds[(int)$aRow['id']] = (int)$aRow['id'];
  865. }
  866. if ($aMissing)
  867. return ['ok' => 0, 'error' => 'Unknown tools: ' . implode(', ', $aMissing) . '. action=catalog to list types.'];
  868. return implode(',', array_values($aIds));
  869. }
  870. protected function _uniqueName(string $sName, string $sTitle): string
  871. {
  872. $s = $sName !== '' ? $sName : $sTitle;
  873. $s = strtolower($s);
  874. $s = preg_replace('/[^a-z0-9_]+/', '_', $s);
  875. $s = trim($s, '_');
  876. if ($s === '')
  877. $s = 'agent';
  878. if (strlen($s) > 80)
  879. $s = substr($s, 0, 80);
  880. $oDb = BxDolDb::getInstance();
  881. $sBase = $s;
  882. $i = 2;
  883. while ($oDb->getOne("SELECT `id` FROM `sys_agents_agents` WHERE `name` = :n LIMIT 1", ['n' => $s])) {
  884. $s = $sBase . '_' . $i;
  885. $i++;
  886. if ($i > 99)
  887. $s = $sBase . '_' . bin2hex(random_bytes(3));
  888. }
  889. return $s;
  890. }
  891. protected function _defaults(): array
  892. {
  893. $oDb = BxDolDb::getInstance();
  894. $aOp = null;
  895. $aCtx = BxDolAIToolMysqlWrite::currentLogContext();
  896. $iFromChat = (int)($aCtx['agent_id'] ?? 0);
  897. if ($iFromChat > 0)
  898. $aOp = $oDb->getRow("SELECT `id`, `model_id`, `profile_id`, `vector_store_id` FROM `sys_agents_agents` WHERE `id` = :id LIMIT 1", ['id' => $iFromChat]);
  899. if (!$aOp) {
  900. $iOpt = (int)getParam('sys_agents_operator_agent');
  901. if ($iOpt > 0)
  902. $aOp = $oDb->getRow("SELECT `id`, `model_id`, `profile_id`, `vector_store_id` FROM `sys_agents_agents` WHERE `id` = :id LIMIT 1", ['id' => $iOpt]);
  903. }
  904. $iModel = (int)($aOp['model_id'] ?? 0) ?: (int)(getParam('sys_agents_model'));
  905. $iProfile = (int)($aOp['profile_id'] ?? 0);
  906. if ($iProfile <= 0)
  907. $iProfile = (int)getParam('sys_profile_bot');
  908. $iStore = (int)($aOp['vector_store_id'] ?? 0);
  909. if ($iStore <= 0 && $oDb->isTableExists('sys_agents_vector_store'))
  910. $iStore = (int)$oDb->getOne("SELECT `id` FROM `sys_agents_vector_store` WHERE `active` = 1 ORDER BY `id` ASC LIMIT 1");
  911. return [
  912. 'model_id' => $iModel,
  913. 'profile_id' => $iProfile,
  914. 'vector_store_id' => $iStore,
  915. 'from_agent_id' => (int)($aOp['id'] ?? 0),
  916. ];
  917. }
  918. /**
  919. * Preset Robot profile and the account that owns it: sys_profile_bot, then the operator agent's profile.
  920. *
  921. * @return array{profile_id:int,account_id:int}
  922. */
  923. protected function _botIdentity(): array
  924. {
  925. $oDb = BxDolDb::getInstance();
  926. $aCandidates = [
  927. (int)getParam('sys_profile_bot'),
  928. (int)($this->_defaults()['profile_id'] ?? 0),
  929. ];
  930. foreach ($aCandidates as $iProfile) {
  931. if ($iProfile <= 0)
  932. continue;
  933. $iAccount = (int)$oDb->getOne("SELECT `account_id` FROM `sys_profiles` WHERE `id` = :id LIMIT 1", ['id' => $iProfile]);
  934. if ($iAccount > 0)
  935. return ['profile_id' => $iProfile, 'account_id' => $iAccount];
  936. }
  937. return ['profile_id' => 0, 'account_id' => 0];
  938. }
  939. /**
  940. * Account of the preset Robot. See _botIdentity().
  941. */
  942. protected function _botAccount(): int
  943. {
  944. return (int)$this->_botIdentity()['account_id'];
  945. }
  946. protected function _onlyExistingColumns(BxDolDb $oDb, string $sTable, array $aRow): array
  947. {
  948. $aOut = [];
  949. foreach ($aRow as $sK => $mixedV) {
  950. if ($oDb->isFieldExists($sTable, $sK))
  951. $aOut[$sK] = $mixedV;
  952. }
  953. return $aOut;
  954. }
  955. protected function _clip(string $s, int $iMax = 0): string
  956. {
  957. $iMax = $iMax ?: self::CLIP;
  958. $s = trim($s);
  959. if (strlen($s) <= $iMax)
  960. return $s;
  961. // at most $iMax bytes, never half a character: substr() split UTF-8 text mid-letter
  962. return mb_strcut($s, 0, $iMax, 'UTF-8');
  963. }
  964. protected function _logSql(string $sOp, string $sTable, string $sPk, string $sPkValue, string $sSql, $mixedBefore, $mixedAfter): void
  965. {
  966. $oDb = BxDolDb::getInstance();
  967. if (!$oDb->isTableExists('sys_agents_sql_log'))
  968. return;
  969. $aCtx = BxDolAIToolMysqlWrite::currentLogContext();
  970. $aSet = [
  971. 'agent_id' => (int)($aCtx['agent_id'] ?? 0),
  972. 'profile_id' => (int)($aCtx['profile_id'] ?? 0),
  973. 'thread_id' => (string)($aCtx['thread_id'] ?? ''),
  974. 'op' => $sOp,
  975. 'table_name' => $sTable,
  976. 'pk_name' => $sPk,
  977. 'pk_value' => $sPkValue,
  978. 'sql_text' => $sSql,
  979. 'before_json' => $mixedBefore ? json_encode($mixedBefore, JSON_UNESCAPED_UNICODE) : null,
  980. 'after_json' => $mixedAfter ? json_encode($mixedAfter, JSON_UNESCAPED_UNICODE) : null,
  981. 'added' => time(),
  982. ];
  983. if ($oDb->isFieldExists('sys_agents_sql_log', 'undone'))
  984. $aSet['undone'] = 0;
  985. $oDb->query("INSERT INTO `sys_agents_sql_log` SET " . $oDb->arrayToSQL($aSet));
  986. }
  987. }
  988. /** @} */