BxDolAIToolAgentCreate.php 52 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107
  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. $sHay = mb_strtolower($aRow['id'] . ' ' . $aRow['type'] . ' ' . $aRow['title']);
  195. if ($sQ !== '' && strpos($sHay, $sQ) === false && $sPreset !== 'comment_reply')
  196. continue;
  197. if ($sPreset === 'comment_reply' && $sQ === '' && !in_array($aRow['type'], self::COMMENT_TOOLS, true))
  198. continue;
  199. $aTools[] = [
  200. 'id' => (int)$aRow['id'],
  201. 'type' => $aRow['type'],
  202. 'title' => $aRow['title'],
  203. 'active' => (int)$aRow['active'],
  204. ];
  205. }
  206. $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");
  207. $aStores = $oDb->isTableExists('sys_agents_vector_store')
  208. ? $oDb->getAll("SELECT `id`, `title`, `type`, `active` FROM `sys_agents_vector_store` WHERE `active` = 1 ORDER BY `title` ASC LIMIT 20")
  209. : [];
  210. $aAgents = $oDb->getAll("SELECT `id`, `name`, `title`, `trigger`, `alert`, `active`, `profile_id`, `model_id` FROM `sys_agents_agents` ORDER BY `id` DESC LIMIT 30");
  211. if ($sQ !== '') {
  212. $aAgents = array_values(array_filter($aAgents, function ($aRow) use ($sQ) {
  213. $sHay = mb_strtolower(($aRow['name'] ?? '') . ' ' . ($aRow['title'] ?? '') . ' ' . ($aRow['alert'] ?? '') . ' ' . ($aRow['trigger'] ?? ''));
  214. return strpos($sHay, $sQ) !== false;
  215. }));
  216. }
  217. $aDefaults = $this->_defaults();
  218. $aProfiles = $this->_listProfiles('');
  219. $sSuggest = $this->_suggestProfileName($sPreset);
  220. return [
  221. 'ok' => 1,
  222. 'version' => self::VERSION,
  223. 'action' => 'catalog',
  224. 'profiles' => $aProfiles,
  225. 'profiles_count' => count($aProfiles),
  226. 'suggested_new_name' => $sSuggest,
  227. 'profile_buttons' => $this->_profileButtons($aProfiles, $sSuggest),
  228. '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.',
  229. 'defaults' => $aDefaults,
  230. 'presets' => self::PRESETS,
  231. '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.',
  232. 'alerts' => $aAlerts,
  233. 'tools' => $aTools,
  234. 'models' => $aModels,
  235. 'vector_stores' => $aStores,
  236. 'agents' => $aAgents,
  237. ];
  238. }
  239. protected function _create(array $aIn, bool $bDry): array
  240. {
  241. $sPreset = $this->_preset($aIn['preset'] ?? '');
  242. $sTitle = trim((string)($aIn['title'] ?? ''));
  243. if ($sTitle === '')
  244. return ['ok' => 0, 'error' => 'title is required'];
  245. $sPrompt = $this->_clip((string)($aIn['prompt_system'] ?? ''));
  246. if (trim($sPrompt) === '')
  247. return ['ok' => 0, 'error' => 'prompt_system is required — this is what the new agent will do'];
  248. $sTrigger = strtolower(trim((string)($aIn['trigger'] ?? '')));
  249. if ($sTrigger === '')
  250. $sTrigger = $this->_triggerForPreset($sPreset);
  251. if (!in_array($sTrigger, self::TRIGGERS, true))
  252. return ['ok' => 0, 'error' => 'unknown trigger. Use: ' . implode(', ', self::TRIGGERS)];
  253. $aDefaults = $this->_defaults();
  254. $iModel = (int)($aIn['model_id'] ?? 0) ?: (int)$aDefaults['model_id'];
  255. $sProfileIn = trim((string)($aIn['profile'] ?? ''));
  256. $sProfileNewIn = trim((string)($aIn['profile_new'] ?? ''));
  257. if ($sProfileIn === '' && $sProfileNewIn === '' && (int)($aIn['profile_id'] ?? 0) <= 0) {
  258. // never fall back to the operator's own profile silently — the user must pick a name or a new one
  259. $aProfiles = $this->_listProfiles('');
  260. $sSuggest = $this->_suggestProfileName($sPreset);
  261. return [
  262. 'ok' => 0,
  263. 'error' => _t('_sys_agents_profile_required_err', _t('_sys_agents_profile_btn_new', $sSuggest)),
  264. 'profiles' => $aProfiles,
  265. 'suggested_new_name' => $sSuggest,
  266. 'profile_buttons' => $this->_profileButtons($aProfiles, $sSuggest),
  267. ];
  268. }
  269. $aProf = $this->_resolveProfile($sProfileIn, $aIn['profile_id'] ?? 0, $sProfileNewIn, $bDry);
  270. if (!empty($aProf['error']))
  271. return ['ok' => 0, 'error' => $aProf['error'], 'candidates' => $aProf['candidates'] ?? []];
  272. $iProfile = (int)($aProf['profile_id'] ?? 0);
  273. $iStore = (int)($aIn['vector_store_id'] ?? 0) ?: (int)$aDefaults['vector_store_id'];
  274. if ($iModel <= 0)
  275. return ['ok' => 0, 'error' => 'no chat model. Pass model_id or activate a chatllm model.'];
  276. if ($iProfile <= 0 && empty($aProf['dry_create']))
  277. return ['ok' => 0, 'error' => 'profile could not be resolved. Pass profile=<name from catalog.profiles> or profile_new=<name>.'];
  278. $oDb = BxDolDb::getInstance();
  279. if (!$oDb->getRow("SELECT `id` FROM `sys_agents_models` WHERE `id` = :id AND `active` = 1 LIMIT 1", ['id' => $iModel]))
  280. return ['ok' => 0, 'error' => "model_id {$iModel} is missing or inactive"];
  281. $sAlert = '';
  282. $aAlertPick = null;
  283. if ($sTrigger === 'alert') {
  284. $aAlertPick = $this->_resolveAlert((string)($aIn['alert'] ?? ''), (string)($aIn['module'] ?? ''), $sPreset);
  285. if (!empty($aAlertPick['error']))
  286. return ['ok' => 0, 'error' => $aAlertPick['error'], 'candidates' => $aAlertPick['candidates'] ?? []];
  287. $sAlert = $aAlertPick['alert'];
  288. }
  289. $sToolsCsv = $this->_resolveToolsCsv((string)($aIn['tools'] ?? ''), $sPreset);
  290. if (is_array($sToolsCsv) && isset($sToolsCsv['error']))
  291. return $sToolsCsv;
  292. if ($sTrigger === 'message') {
  293. $aDup = $oDb->getRow("SELECT `id`, `name` FROM `sys_agents_agents` WHERE `trigger` = 'message' AND `profile_id` = :p LIMIT 1", ['p' => $iProfile]);
  294. if ($aDup)
  295. return ['ok' => 0, 'error' => "profile_id {$iProfile} already has message-agent {$aDup['id']} ({$aDup['name']}). One message-agent per profile."];
  296. }
  297. if ($sTrigger === 'scheduler' && trim((string)($aIn['scheduler_cron'] ?? '')) === '')
  298. return ['ok' => 0, 'error' => 'scheduler_cron is required for trigger=scheduler'];
  299. if ($sTrigger === 'webhook' && trim((string)($aIn['webhook_key'] ?? '')) === '')
  300. return ['ok' => 0, 'error' => 'webhook_key is required for trigger=webhook'];
  301. if ($sTrigger === 'form-input' && (trim((string)($aIn['form_object'] ?? '')) === '' || trim((string)($aIn['form_input'] ?? '')) === ''))
  302. return ['ok' => 0, 'error' => 'form_object and form_input are required for trigger=form-input'];
  303. $iAsync = $aIn['async'];
  304. if ($iAsync === null || $iAsync === '')
  305. $iAsync = in_array($sTrigger, ['alert', 'webhook', 'message'], true) ? 1 : 0;
  306. $iAsync = (int)$iAsync ? 1 : 0;
  307. if (in_array($sTrigger, ['manual', 'scheduler'], true))
  308. $iAsync = 0;
  309. $iActive = isset($aIn['active']) && $aIn['active'] !== '' && $aIn['active'] !== null
  310. ? ((int)$aIn['active'] ? 1 : 0)
  311. : 1;
  312. $sName = $this->_uniqueName((string)($aIn['name'] ?? ''), $sTitle);
  313. $sPromptTools = $this->_clip((string)($aIn['prompt_tools'] ?? ''));
  314. if ($sPreset === 'comment_reply') {
  315. $sPromptTools = trim($sPromptTools . "\n\n" . self::COMMENT_PROMPT_TOOLS);
  316. }
  317. $sPromptSteps = $this->_clip((string)($aIn['prompt_steps'] ?? ''));
  318. if ($sPreset === 'comment_reply' && $sPromptSteps === '') {
  319. $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.";
  320. }
  321. $aRow = [
  322. 'title' => $this->_clip($sTitle, 255),
  323. 'name' => $sName,
  324. 'description' => $this->_clip((string)($aIn['description'] ?? ''), 2000),
  325. 'icon' => '',
  326. 'model_id' => $iModel,
  327. 'profile_id' => $iProfile,
  328. 'acl_levels' => 0,
  329. 'trigger' => $sTrigger,
  330. 'async' => $iAsync,
  331. 'alert' => $sAlert,
  332. 'scheduler_cron' => trim((string)($aIn['scheduler_cron'] ?? '')),
  333. 'webhook_key' => trim((string)($aIn['webhook_key'] ?? '')),
  334. 'message_profile_id' => (int)($aIn['message_profile_id'] ?? 0),
  335. 'form_object' => trim((string)($aIn['form_object'] ?? '')),
  336. 'form_input' => trim((string)($aIn['form_input'] ?? '')),
  337. 'max_turns' => 40,
  338. 'chat_ttl_min' => 0,
  339. 'max_input_chars' => 2000,
  340. 'max_tokens' => 1024,
  341. 'max_sessions_per_hour' => 3,
  342. 'max_sessions_per_day' => 10,
  343. 'vector_store_id' => $iStore,
  344. 'chat_history_context' => $sTrigger === 'alert' ? 0 : 50000,
  345. 'tools' => $sToolsCsv,
  346. 'tools_max_run' => $sPreset === 'comment_reply' ? 8 : 10,
  347. 'prompt_system' => $sPrompt,
  348. 'prompt_steps' => $sPromptSteps,
  349. 'prompt_output' => $this->_clip((string)($aIn['prompt_output'] ?? '')),
  350. 'prompt_tools' => $sPromptTools,
  351. 'added' => time(),
  352. 'active' => $iActive,
  353. ];
  354. $aRow = $this->_onlyExistingColumns($oDb, 'sys_agents_agents', $aRow);
  355. $aOut = [
  356. 'ok' => 1,
  357. 'action' => $bDry ? 'dry_run' : 'create',
  358. 'preset' => $sPreset,
  359. 'studio' => '/studio/agents.php?page=agents',
  360. 'row' => [
  361. 'name' => $aRow['name'],
  362. 'title' => $aRow['title'],
  363. 'trigger' => $aRow['trigger'],
  364. 'alert' => $aRow['alert'] ?? '',
  365. 'async' => (int)($aRow['async'] ?? 0),
  366. 'active' => (int)$aRow['active'],
  367. 'model_id' => (int)$aRow['model_id'],
  368. 'profile_id' => (int)$aRow['profile_id'],
  369. 'profile_name' => $aProf['name'] ?? '',
  370. 'profile_created' => (int)($aProf['created'] ?? 0),
  371. 'vector_store_id' => (int)($aRow['vector_store_id'] ?? 0),
  372. 'tools' => $aRow['tools'],
  373. 'prompt_system_chars' => strlen($aRow['prompt_system']),
  374. ],
  375. ];
  376. if (!empty($aAlertPick['warning']))
  377. $aOut['warning'] = $aAlertPick['warning'];
  378. if ($bDry)
  379. return $aOut;
  380. $mixed = $oDb->query("INSERT INTO `sys_agents_agents` SET " . $oDb->arrayToSQL($aRow));
  381. if ($mixed === false)
  382. return ['ok' => 0, 'error' => 'INSERT failed'];
  383. $iId = (int)$oDb->lastId();
  384. $oDb->cleanCache('sys_agents_with_alert');
  385. $this->_logSql('insert', 'sys_agents_agents', 'id', (string)$iId, 'INSERT agent ' . $aRow['name'], null, $aRow);
  386. $aOut['id'] = $iId;
  387. $aOut['row']['id'] = $iId;
  388. $aOut['next'] = $iActive
  389. ? 'Agent is active. Test the event (post a comment) or open Studio Agents grid.'
  390. : 'Created inactive. action=update agent_id=' . $iId . ' active=1 to turn on.';
  391. return $aOut;
  392. }
  393. protected function _update(int $iId, array $aIn, bool $bDry): array
  394. {
  395. if ($iId <= 0)
  396. return ['ok' => 0, 'error' => 'agent_id is required for update'];
  397. $oDb = BxDolDb::getInstance();
  398. $aBefore = $oDb->getRow("SELECT * FROM `sys_agents_agents` WHERE `id` = :id LIMIT 1", ['id' => $iId]);
  399. if (!$aBefore)
  400. return ['ok' => 0, 'error' => "agent {$iId} not found"];
  401. $aSet = [];
  402. foreach (['title', 'description', 'prompt_system', 'prompt_steps', 'prompt_output', 'prompt_tools'] as $sF) {
  403. if (isset($aIn[$sF]) && $aIn[$sF] !== '' && $aIn[$sF] !== null)
  404. $aSet[$sF] = $this->_clip((string)$aIn[$sF], $sF === 'title' ? 255 : self::CLIP);
  405. }
  406. if (isset($aIn['tools']) && $aIn['tools'] !== '' && $aIn['tools'] !== null) {
  407. $sCsv = $this->_resolveToolsCsv((string)$aIn['tools'], $this->_preset($aIn['preset'] ?? ''));
  408. if (is_array($sCsv) && isset($sCsv['error']))
  409. return $sCsv;
  410. $aSet['tools'] = $sCsv;
  411. }
  412. if ((isset($aIn['alert']) && $aIn['alert'] !== '') || (isset($aIn['module']) && $aIn['module'] !== '')) {
  413. $aAlertPick = $this->_resolveAlert((string)($aIn['alert'] ?? ''), (string)($aIn['module'] ?? ''), $this->_preset($aIn['preset'] ?? 'comment_reply'));
  414. if (!empty($aAlertPick['error']))
  415. return ['ok' => 0, 'error' => $aAlertPick['error'], 'candidates' => $aAlertPick['candidates'] ?? []];
  416. $aSet['alert'] = $aAlertPick['alert'];
  417. $aSet['trigger'] = 'alert';
  418. }
  419. if ($aIn['async'] !== null && $aIn['async'] !== '')
  420. $aSet['async'] = (int)$aIn['async'] ? 1 : 0;
  421. if ($aIn['active'] !== null && $aIn['active'] !== '')
  422. $aSet['active'] = (int)$aIn['active'] ? 1 : 0;
  423. $bWantProfile = (isset($aIn['profile']) && $aIn['profile'] !== '' && $aIn['profile'] !== null)
  424. || (isset($aIn['profile_new']) && $aIn['profile_new'] !== '' && $aIn['profile_new'] !== null)
  425. || !empty($aIn['profile_id']);
  426. $aProf = ['name' => '', 'created' => 0];
  427. if ($bWantProfile) {
  428. $aProf = $this->_resolveProfile($aIn['profile'] ?? '', $aIn['profile_id'] ?? 0, $aIn['profile_new'] ?? '', $bDry);
  429. if (!empty($aProf['error']))
  430. return ['ok' => 0, 'error' => $aProf['error'], 'candidates' => $aProf['candidates'] ?? []];
  431. if (!empty($aProf['profile_id']))
  432. $aSet['profile_id'] = (int)$aProf['profile_id'];
  433. }
  434. if (!$aSet)
  435. return ['ok' => 0, 'error' => 'nothing to update'];
  436. $aSet = $this->_onlyExistingColumns($oDb, 'sys_agents_agents', $aSet);
  437. if ($bDry) {
  438. return ['ok' => 1, 'action' => 'dry_run', 'id' => $iId, 'set' => array_keys($aSet)];
  439. }
  440. $b = $oDb->query("UPDATE `sys_agents_agents` SET " . $oDb->arrayToSQL($aSet) . " WHERE `id` = :id", ['id' => $iId]);
  441. if ($b === false)
  442. return ['ok' => 0, 'error' => 'UPDATE failed'];
  443. $oDb->cleanCache('sys_agents_with_alert');
  444. $aAfter = $oDb->getRow("SELECT * FROM `sys_agents_agents` WHERE `id` = :id LIMIT 1", ['id' => $iId]);
  445. $this->_logSql('update', 'sys_agents_agents', 'id', (string)$iId, 'UPDATE agent ' . $iId, $aBefore, $aAfter ?: null);
  446. return [
  447. 'ok' => 1,
  448. 'action' => 'update',
  449. 'id' => $iId,
  450. 'name' => $aAfter['name'] ?? $aBefore['name'],
  451. 'changed' => array_keys($aSet),
  452. 'active' => (int)($aAfter['active'] ?? 0),
  453. 'profile_id' => (int)($aAfter['profile_id'] ?? $aBefore['profile_id'] ?? 0),
  454. 'profile_name' => $aProf['name'] ?? '',
  455. 'profile_created' => (int)($aProf['created'] ?? 0),
  456. 'studio' => '/studio/agents.php?page=agents',
  457. ];
  458. }
  459. protected function _preset($s): string
  460. {
  461. $s = strtolower(trim((string)$s));
  462. if ($s === '' || $s === 'custom')
  463. return 'custom';
  464. if (in_array($s, self::PRESETS, true))
  465. return $s;
  466. return 'custom';
  467. }
  468. protected function _triggerForPreset(string $sPreset): string
  469. {
  470. if ($sPreset === 'comment_reply')
  471. return 'alert';
  472. if ($sPreset === 'message')
  473. return 'message';
  474. if ($sPreset === 'manual')
  475. return 'manual';
  476. return 'alert';
  477. }
  478. protected function _resolveModule(string $s): string
  479. {
  480. $s = trim($s);
  481. if ($s === '')
  482. return '';
  483. $sLow = mb_strtolower($s);
  484. $aNames = $this->_moduleNames();
  485. if (isset($aNames[$sLow]))
  486. return $aNames[$sLow];
  487. if (preg_match('/^[a-z0-9_]+$/i', $s))
  488. return $s;
  489. return '';
  490. }
  491. /**
  492. * Labels that mean a module: machine name, uri, install title, and the current-language title from _t().
  493. * Enabled modules claim a label before disabled ones. A name or uri is never overwritten by a title.
  494. *
  495. * @return array<string,string>
  496. */
  497. protected function _moduleNames(): array
  498. {
  499. if ($this->_aModuleNames !== null)
  500. return $this->_aModuleNames;
  501. $this->_aModuleNames = [];
  502. $oDb = BxDolDb::getInstance();
  503. $aRows = $oDb->getAll("SELECT `name`, `uri`, `title` FROM `sys_modules` WHERE `name` <> '' ORDER BY `enabled` DESC, `name` ASC");
  504. foreach ($aRows as $aRow)
  505. $this->_rememberModule((string)$aRow['name'], (string)$aRow['name']);
  506. foreach ($aRows as $aRow)
  507. $this->_rememberModule((string)$aRow['uri'], (string)$aRow['name']);
  508. foreach ($aRows as $aRow) {
  509. $sName = (string)$aRow['name'];
  510. $this->_rememberModule((string)$aRow['title'], $sName);
  511. $sKey = BxDolModule::getTitleKey((string)$aRow['uri']);
  512. $sTitle = _t($sKey);
  513. if ($sTitle !== $sKey)
  514. $this->_rememberModule($sTitle, $sName);
  515. }
  516. return $this->_aModuleNames;
  517. }
  518. protected function _rememberModule(string $sLabel, string $sName): void
  519. {
  520. $sLabel = mb_strtolower(trim($sLabel));
  521. if ($sLabel === '' || isset($this->_aModuleNames[$sLabel]))
  522. return;
  523. $this->_aModuleNames[$sLabel] = $sName;
  524. }
  525. protected function _resolveAlert(string $sAlert, string $sModule, string $sPreset): array
  526. {
  527. $sAlert = trim($sAlert);
  528. $sAlert = str_replace([' ', '/', '|'], ':', $sAlert);
  529. $sAlert = preg_replace('/:+/', ':', $sAlert);
  530. if ($sAlert !== '' && preg_match('/^[a-zA-Z0-9_]+:[a-zA-Z0-9_]+$/', $sAlert)) {
  531. $aWarn = $this->_alertInLog($sAlert) ? '' : 'Alert is not in sys_alerts_log yet (never fired). It will run once that unit:action happens.';
  532. return ['alert' => $sAlert, 'warning' => $aWarn];
  533. }
  534. $sMod = $this->_resolveModule($sModule !== '' ? $sModule : $sAlert);
  535. $aCandidates = $this->_commentAlerts($sMod);
  536. if ($sPreset === 'comment_reply' || $sMod !== '') {
  537. if (count($aCandidates) === 1)
  538. return ['alert' => $aCandidates[0]['alert']];
  539. if (count($aCandidates) > 1)
  540. return ['error' => 'Several comment alerts match. Pass alert=unit:action.', 'candidates' => $aCandidates];
  541. if ($sMod !== '')
  542. 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' => []];
  543. }
  544. return ['error' => 'Pass alert as unit:action (bx_forum:commentPost) or module=discussions / bx_forum. action=catalog preset=comment_reply to list.', 'candidates' => $aCandidates];
  545. }
  546. protected function _commentAlerts(string $sMod): array
  547. {
  548. $oDb = BxDolDb::getInstance();
  549. if (!$oDb->isTableExists('sys_alerts_log'))
  550. return [];
  551. $aParams = [];
  552. $sWhere = "`action` IN (" . $oDb->implode_escape(self::COMMENT_ACTIONS) . ")";
  553. if ($sMod !== '') {
  554. $sWhere .= " AND `unit` = :u";
  555. $aParams['u'] = $sMod;
  556. }
  557. $aRows = $oDb->getAll("SELECT `unit`, `action`, `counter_24h` FROM `sys_alerts_log` WHERE {$sWhere} ORDER BY `counter_24h` DESC LIMIT 20", $aParams);
  558. $aOut = [];
  559. foreach ($aRows as $aRow) {
  560. $aOut[] = [
  561. 'alert' => $aRow['unit'] . ':' . $aRow['action'],
  562. 'fired_24h' => (int)$aRow['counter_24h'],
  563. ];
  564. }
  565. return $aOut;
  566. }
  567. protected function _alertInLog(string $sAlert): bool
  568. {
  569. $oDb = BxDolDb::getInstance();
  570. if (!$oDb->isTableExists('sys_alerts_log'))
  571. return false;
  572. $a = explode(':', $sAlert, 2);
  573. $s = $oDb->getOne("SELECT CONCAT(`unit`, ':', `action`) FROM `sys_alerts_log` WHERE `unit` = :u AND `action` = :a LIMIT 1", [
  574. 'u' => $a[0] ?? '',
  575. 'a' => $a[1] ?? '',
  576. ]);
  577. return (bool)$s;
  578. }
  579. protected function _listProfiles(string $sQuery = ''): array
  580. {
  581. $aOut = [];
  582. $aSeen = [];
  583. $oDb = BxDolDb::getInstance();
  584. // each source is isolated: one failing query must not empty the whole list
  585. $aDefaults = $this->_defaults();
  586. $this->_pushProfile($aOut, $aSeen, (int)$aDefaults['profile_id'], '', 'default');
  587. try {
  588. // profiles of the preset Robot account (sys_profile_bot) go first — new profiles are created there
  589. $iBotAccount = $this->_botAccount();
  590. if ($iBotAccount > 0) {
  591. $aRows = $oDb->getAll("SELECT `id` FROM `sys_profiles` WHERE `account_id` = :a AND `status` = 'active' ORDER BY `id` ASC LIMIT 50", ['a' => $iBotAccount]);
  592. foreach ($aRows as $aRow)
  593. $this->_pushProfile($aOut, $aSeen, (int)$aRow['id'], '', 'robot');
  594. }
  595. } catch (Throwable $o) {
  596. bx_log('sys_agents', 'agent_create profiles (robot account): ' . $o->getMessage(), BX_LOG_WARN);
  597. }
  598. try {
  599. $iOpAccount = (int)$oDb->getOne("SELECT `account_id` FROM `sys_profiles` WHERE `id` = :id LIMIT 1", ['id' => (int)$aDefaults['profile_id']]);
  600. if ($iOpAccount > 0) {
  601. $aRows = $oDb->getAll("SELECT `id` FROM `sys_profiles` WHERE `account_id` = :a AND `status` = 'active' ORDER BY `id` ASC LIMIT 50", ['a' => $iOpAccount]);
  602. foreach ($aRows as $aRow)
  603. $this->_pushProfile($aOut, $aSeen, (int)$aRow['id'], '', 'operator');
  604. }
  605. } catch (Throwable $o) {
  606. bx_log('sys_agents', 'agent_create profiles (account): ' . $o->getMessage(), BX_LOG_WARN);
  607. }
  608. try {
  609. if (function_exists('bx_srv')) {
  610. $aOpts = bx_srv('system', 'get_options_agents_profile', [false], 'TemplServices');
  611. if (is_array($aOpts)) {
  612. foreach ($aOpts as $aOpt)
  613. $this->_pushProfile($aOut, $aSeen, (int)($aOpt['key'] ?? 0), (string)($aOpt['value'] ?? ''), 'operator');
  614. }
  615. }
  616. } catch (Throwable $o) {
  617. bx_log('sys_agents', 'agent_create profiles (service): ' . $o->getMessage(), BX_LOG_WARN);
  618. }
  619. try {
  620. $iRoleAdmin = defined('BX_DOL_ROLE_ADMIN') ? (int)BX_DOL_ROLE_ADMIN : 2;
  621. $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", [
  622. 'r' => $iRoleAdmin,
  623. ]);
  624. foreach ($aRows as $aRow)
  625. $this->_pushProfile($aOut, $aSeen, (int)$aRow['id'], '', 'operator');
  626. } catch (Throwable $o) {
  627. bx_log('sys_agents', 'agent_create profiles (admins): ' . $o->getMessage(), BX_LOG_WARN);
  628. }
  629. try {
  630. $aUsed = $oDb->getColumn("SELECT DISTINCT `profile_id` FROM `sys_agents_agents` WHERE `profile_id` > 0 ORDER BY `profile_id` DESC LIMIT 30");
  631. foreach ($aUsed as $iId)
  632. $this->_pushProfile($aOut, $aSeen, (int)$iId, '', 'agent');
  633. } catch (Throwable $o) {
  634. bx_log('sys_agents', 'agent_create profiles (agents): ' . $o->getMessage(), BX_LOG_WARN);
  635. }
  636. $sQ = trim($sQuery);
  637. if ($sQ !== '' && mb_strlen($sQ) <= 80 && $oDb->isTableExists('bx_persons_data')) {
  638. $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", [
  639. 'q' => '%' . $sQ . '%',
  640. ]);
  641. foreach ($aRows as $aRow)
  642. $this->_pushProfile($aOut, $aSeen, (int)$aRow['id'], (string)$aRow['fullname'], 'search');
  643. }
  644. return $aOut;
  645. }
  646. protected function _profileButtons(array $aProfiles, string $sSuggest): array
  647. {
  648. $aOut = [];
  649. foreach ($aProfiles as $aP) {
  650. $s = (string)($aP['name'] ?? '');
  651. if ($s !== '' && !in_array($s, $aOut, true))
  652. $aOut[] = $s;
  653. }
  654. $aOut[] = _t('_sys_agents_profile_btn_new', $sSuggest);
  655. return $aOut;
  656. }
  657. protected function _suggestProfileName(string $sPreset): string
  658. {
  659. return _t($this->_preset($sPreset) === 'comment_reply' ? '_sys_agents_profile_name_commenter' : '_sys_agents_profile_name_bot');
  660. }
  661. /**
  662. * Accounts whose profiles an agent may act as: the operators' (the profiles
  663. * Studio offers, get_options_agents_profile) and the Robot account's, where
  664. * profile_new creates them. Never a member's: an agent on a member's profile
  665. * would post, and as a message agent answer their messages, in their name.
  666. *
  667. * @return int[]
  668. */
  669. protected function _allowedAccounts(): array
  670. {
  671. if ($this->_aAllowedAccounts === null) {
  672. $aIds = array_map('intval', (array)BxDolAccountQuery::getInstance()->getOperators());
  673. $aIds[] = $this->_botAccount();
  674. $this->_aAllowedAccounts = array_values(array_unique(array_filter($aIds)));
  675. }
  676. return $this->_aAllowedAccounts;
  677. }
  678. protected function _isAllowedAgentProfile(int $iProfileId): bool
  679. {
  680. if ($iProfileId <= 0)
  681. return false;
  682. $iAccount = (int)BxDolDb::getInstance()->getOne("SELECT `account_id` FROM `sys_profiles` WHERE `id` = :id LIMIT 1", ['id' => $iProfileId]);
  683. return $iAccount > 0 && in_array($iAccount, $this->_allowedAccounts(), true);
  684. }
  685. /** `AND p.account_id IN (...)` for a query over `sys_profiles` aliased `p`. */
  686. protected function _allowedAccountsSql(): string
  687. {
  688. $aIds = $this->_allowedAccounts();
  689. return ' AND p.`account_id` IN (' . ($aIds ? implode(',', $aIds) : '0') . ')';
  690. }
  691. protected function _pushProfile(array &$aOut, array &$aSeen, int $iId, string $sFallback, string $sSource): void
  692. {
  693. if ($iId <= 0 || isset($aSeen[$iId]) || !$this->_isAllowedAgentProfile($iId))
  694. return;
  695. try {
  696. $sName = $this->_profileDisplayName($iId, $sFallback);
  697. } catch (Throwable $o) {
  698. $sName = $sFallback;
  699. }
  700. if ($sName === '')
  701. $sName = '#' . $iId;
  702. $aOut[] = ['id' => $iId, 'name' => $sName, 'source' => $sSource];
  703. $aSeen[$iId] = 1;
  704. }
  705. protected function _shortDisplayName(string $s): array
  706. {
  707. $s = trim(preg_replace('/\s+/u', ' ', $s));
  708. $sPrefix = trim(_t('_sys_agents_profile_btn_new', ''));
  709. if ($sPrefix !== '' && $sPrefix !== '_sys_agents_profile_btn_new')
  710. $s = trim((string)preg_replace('/^' . preg_quote($sPrefix, '/') . '\s*/iu', '', $s));
  711. if ($s === '')
  712. return ['error' => 'profile name is empty'];
  713. $iWords = count(preg_split('/\s+/u', $s, -1, PREG_SPLIT_NO_EMPTY));
  714. if (mb_strlen($s) > self::PROFILE_NAME_MAX || $iWords > 3 || preg_match('/[\r\n.!?—–;:]/u', $s)) {
  715. return ['error' => _t('_sys_agents_profile_new_err', _t('_sys_agents_profile_name_commenter'), _t('_sys_agents_profile_name_bot'))];
  716. }
  717. return ['name' => $s];
  718. }
  719. protected function _profileDisplayName(int $iId, string $sFallback = ''): string
  720. {
  721. $o = BxDolProfile::getInstance($iId);
  722. if ($o)
  723. return $o->getDisplayName();
  724. if ($sFallback !== '')
  725. return $sFallback;
  726. return '#' . $iId;
  727. }
  728. protected function _resolveProfile($sProfile, $mixedId, $sNew, bool $bDry): array
  729. {
  730. $sNew = trim((string)$sNew);
  731. if ($sNew !== '')
  732. return $this->_createPersonProfile($sNew, $bDry);
  733. $sProfile = trim((string)$sProfile);
  734. $iId = (int)$mixedId;
  735. if ($sProfile !== '' && preg_match('/^\d+$/', $sProfile))
  736. $iId = (int)$sProfile;
  737. if ($iId > 0) {
  738. $o = BxDolProfile::getInstance($iId);
  739. if (!$o)
  740. return ['error' => "profile {$iId} not found"];
  741. if (!$this->_isAllowedAgentProfile($iId))
  742. 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>."];
  743. return ['profile_id' => $iId, 'name' => $o->getDisplayName(), 'created' => 0];
  744. }
  745. if ($sProfile === '')
  746. return ['profile_id' => 0, 'name' => '', 'created' => 0];
  747. $sProfile = ltrim($sProfile, '@');
  748. $aHits = [];
  749. foreach ($this->_listProfiles($sProfile) as $aP) {
  750. if (mb_strtolower((string)$aP['name']) === mb_strtolower($sProfile) || (string)$aP['id'] === $sProfile)
  751. $aHits[(int)$aP['id']] = $aP;
  752. }
  753. $oDb = BxDolDb::getInstance();
  754. if ($oDb->isTableExists('bx_persons_data')) {
  755. $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", [
  756. 'n' => $sProfile,
  757. ]);
  758. foreach ($aRows as $aRow)
  759. $aHits[(int)$aRow['id']] = ['id' => (int)$aRow['id'], 'name' => $aRow['fullname']];
  760. if (!$aHits) {
  761. $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", [
  762. 'n' => '%' . $sProfile . '%',
  763. ]);
  764. foreach ($aRows as $aRow)
  765. $aHits[(int)$aRow['id']] = ['id' => (int)$aRow['id'], 'name' => $aRow['fullname']];
  766. }
  767. }
  768. $aList = array_values($aHits);
  769. if (count($aList) === 1)
  770. return ['profile_id' => (int)$aList[0]['id'], 'name' => $aList[0]['name'], 'created' => 0];
  771. if (count($aList) > 1)
  772. return ['error' => 'Several profiles match. Pass profile=exact name from catalog.profiles.', 'candidates' => $aList];
  773. return ['error' => 'No profile named «' . $sProfile . '». Pick from catalog.profiles or pass profile_new=' . $sProfile . '.'];
  774. }
  775. protected function _createPersonProfile(string $sName, bool $bDry): array
  776. {
  777. $aName = $this->_shortDisplayName($sName);
  778. if (!empty($aName['error']))
  779. return $aName;
  780. $sName = $aName['name'];
  781. $oDb = BxDolDb::getInstance();
  782. if (!$oDb->isTableExists('bx_persons_data'))
  783. return ['error' => 'bx_persons_data is missing — cannot create a Persons profile'];
  784. // new bot profiles always live under the preset Robot account (sys_profile_bot), not under the operator.
  785. // author is that Robot profile's id; sys_profiles.account_id is the account that owns it.
  786. $aBot = $this->_botIdentity();
  787. $iAuthor = (int)$aBot['profile_id'];
  788. $iAccount = (int)$aBot['account_id'];
  789. if ($iAuthor <= 0 || $iAccount <= 0)
  790. return ['error' => 'Robot account not found: set sys_profile_bot (Studio → Settings → Bot profile), then retry.'];
  791. $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", [
  792. 'a' => $iAccount,
  793. 'n' => $sName,
  794. ]);
  795. if ($aExist)
  796. return ['profile_id' => (int)$aExist['id'], 'name' => $sName, 'created' => 0];
  797. if ($bDry)
  798. return ['profile_id' => 0, 'name' => $sName, 'created' => 1, 'dry_create' => 1];
  799. $iNow = time();
  800. $iPublic = defined('BX_DOL_PG_ALL') ? BX_DOL_PG_ALL : 3;
  801. $aPerson = $this->_onlyExistingColumns($oDb, 'bx_persons_data', [
  802. 'author' => $iAuthor,
  803. 'added' => $iNow,
  804. 'changed' => $iNow,
  805. 'fullname' => $sName,
  806. 'last_name' => '',
  807. 'allow_view_to' => $iPublic,
  808. 'allow_post_to' => $iPublic,
  809. 'allow_contact_to' => $iPublic,
  810. ]);
  811. $mixed = $oDb->query("INSERT INTO `bx_persons_data` SET " . $oDb->arrayToSQL($aPerson));
  812. if ($mixed === false)
  813. return ['error' => 'failed to insert bx_persons_data'];
  814. $iContent = (int)$oDb->lastId();
  815. $this->_logSql('insert', 'bx_persons_data', 'id', (string)$iContent, 'INSERT person ' . $sName, null, array_merge($aPerson, ['id' => $iContent]));
  816. $iAction = defined('BX_PROFILE_ACTION_AUTO') ? BX_PROFILE_ACTION_AUTO : 0;
  817. $iProfile = (int)BxDolProfile::add($iAction, $iAccount, $iContent, 'active', 'bx_persons');
  818. if ($iProfile <= 0)
  819. return ['error' => 'person row created but sys_profiles insert failed', 'content_id' => $iContent];
  820. $this->_logSql('insert', 'sys_profiles', 'id', (string)$iProfile, 'INSERT profile ' . $sName, null, [
  821. 'id' => $iProfile,
  822. 'account_id' => $iAccount,
  823. 'content_id' => $iContent,
  824. 'type' => 'bx_persons',
  825. 'status' => 'active',
  826. ]);
  827. return ['profile_id' => $iProfile, 'name' => $sName, 'created' => 1];
  828. }
  829. protected function _resolveToolsCsv(string $sTools, string $sPreset)
  830. {
  831. $oDb = BxDolDb::getInstance();
  832. $aWanted = [];
  833. $sTools = trim($sTools);
  834. if ($sTools !== '') {
  835. foreach (preg_split('/[,\s]+/', $sTools) as $sTok) {
  836. $sTok = trim($sTok);
  837. if ($sTok === '')
  838. continue;
  839. $aWanted[] = $sTok;
  840. }
  841. } else if ($sPreset === 'comment_reply') {
  842. $aWanted = self::COMMENT_TOOLS;
  843. }
  844. if (!$aWanted)
  845. return '';
  846. $aIds = [];
  847. $aMissing = [];
  848. foreach ($aWanted as $sTok) {
  849. if (preg_match('/^\d+$/', $sTok)) {
  850. $aRow = $oDb->getRow("SELECT `id`, `type`, `active` FROM `sys_agents_tools` WHERE `id` = :id LIMIT 1", ['id' => (int)$sTok]);
  851. } else {
  852. $aRow = $oDb->getRow("SELECT `id`, `type`, `active` FROM `sys_agents_tools` WHERE `type` = :t ORDER BY `active` DESC, `id` ASC LIMIT 1", ['t' => $sTok]);
  853. }
  854. if (!$aRow) {
  855. $aMissing[] = $sTok;
  856. continue;
  857. }
  858. if (in_array($aRow['type'], self::FORBIDDEN_TOOL_TYPES, true))
  859. return ['ok' => 0, 'error' => "Refusing stock mysql_write on a created agent. Use mysql_write_safe if you really need writes."];
  860. $aIds[(int)$aRow['id']] = (int)$aRow['id'];
  861. }
  862. if ($aMissing)
  863. return ['ok' => 0, 'error' => 'Unknown tools: ' . implode(', ', $aMissing) . '. action=catalog to list types.'];
  864. return implode(',', array_values($aIds));
  865. }
  866. protected function _uniqueName(string $sName, string $sTitle): string
  867. {
  868. $s = $sName !== '' ? $sName : $sTitle;
  869. $s = strtolower($s);
  870. $s = preg_replace('/[^a-z0-9_]+/', '_', $s);
  871. $s = trim($s, '_');
  872. if ($s === '')
  873. $s = 'agent';
  874. if (strlen($s) > 80)
  875. $s = substr($s, 0, 80);
  876. $oDb = BxDolDb::getInstance();
  877. $sBase = $s;
  878. $i = 2;
  879. while ($oDb->getOne("SELECT `id` FROM `sys_agents_agents` WHERE `name` = :n LIMIT 1", ['n' => $s])) {
  880. $s = $sBase . '_' . $i;
  881. $i++;
  882. if ($i > 99)
  883. $s = $sBase . '_' . bin2hex(random_bytes(3));
  884. }
  885. return $s;
  886. }
  887. protected function _defaults(): array
  888. {
  889. $oDb = BxDolDb::getInstance();
  890. $aOp = null;
  891. $aCtx = BxDolAIToolMysqlWrite::currentLogContext();
  892. $iFromChat = (int)($aCtx['agent_id'] ?? 0);
  893. if ($iFromChat > 0)
  894. $aOp = $oDb->getRow("SELECT `id`, `model_id`, `profile_id`, `vector_store_id` FROM `sys_agents_agents` WHERE `id` = :id LIMIT 1", ['id' => $iFromChat]);
  895. if (!$aOp) {
  896. $iOpt = (int)getParam('sys_agents_operator_agent');
  897. if ($iOpt > 0)
  898. $aOp = $oDb->getRow("SELECT `id`, `model_id`, `profile_id`, `vector_store_id` FROM `sys_agents_agents` WHERE `id` = :id LIMIT 1", ['id' => $iOpt]);
  899. }
  900. $iModel = (int)($aOp['model_id'] ?? 0) ?: (int)(getParam('sys_agents_model'));
  901. $iProfile = (int)($aOp['profile_id'] ?? 0);
  902. if ($iProfile <= 0)
  903. $iProfile = (int)getParam('sys_profile_bot');
  904. $iStore = (int)($aOp['vector_store_id'] ?? 0);
  905. if ($iStore <= 0 && $oDb->isTableExists('sys_agents_vector_store'))
  906. $iStore = (int)$oDb->getOne("SELECT `id` FROM `sys_agents_vector_store` WHERE `active` = 1 ORDER BY `id` ASC LIMIT 1");
  907. return [
  908. 'model_id' => $iModel,
  909. 'profile_id' => $iProfile,
  910. 'vector_store_id' => $iStore,
  911. 'from_agent_id' => (int)($aOp['id'] ?? 0),
  912. ];
  913. }
  914. /**
  915. * Preset Robot profile and the account that owns it: sys_profile_bot, then the operator agent's profile.
  916. *
  917. * @return array{profile_id:int,account_id:int}
  918. */
  919. protected function _botIdentity(): array
  920. {
  921. $oDb = BxDolDb::getInstance();
  922. $aCandidates = [
  923. (int)getParam('sys_profile_bot'),
  924. (int)($this->_defaults()['profile_id'] ?? 0),
  925. ];
  926. foreach ($aCandidates as $iProfile) {
  927. if ($iProfile <= 0)
  928. continue;
  929. $iAccount = (int)$oDb->getOne("SELECT `account_id` FROM `sys_profiles` WHERE `id` = :id LIMIT 1", ['id' => $iProfile]);
  930. if ($iAccount > 0)
  931. return ['profile_id' => $iProfile, 'account_id' => $iAccount];
  932. }
  933. return ['profile_id' => 0, 'account_id' => 0];
  934. }
  935. /**
  936. * Account of the preset Robot. See _botIdentity().
  937. */
  938. protected function _botAccount(): int
  939. {
  940. return (int)$this->_botIdentity()['account_id'];
  941. }
  942. protected function _onlyExistingColumns(BxDolDb $oDb, string $sTable, array $aRow): array
  943. {
  944. $aOut = [];
  945. foreach ($aRow as $sK => $mixedV) {
  946. if ($oDb->isFieldExists($sTable, $sK))
  947. $aOut[$sK] = $mixedV;
  948. }
  949. return $aOut;
  950. }
  951. protected function _clip(string $s, int $iMax = 0): string
  952. {
  953. $iMax = $iMax ?: self::CLIP;
  954. $s = trim($s);
  955. if (strlen($s) <= $iMax)
  956. return $s;
  957. // at most $iMax bytes, never half a character: substr() split UTF-8 text mid-letter
  958. return mb_strcut($s, 0, $iMax, 'UTF-8');
  959. }
  960. protected function _logSql(string $sOp, string $sTable, string $sPk, string $sPkValue, string $sSql, $mixedBefore, $mixedAfter): void
  961. {
  962. $oDb = BxDolDb::getInstance();
  963. if (!$oDb->isTableExists('sys_agents_sql_log'))
  964. return;
  965. $aCtx = BxDolAIToolMysqlWrite::currentLogContext();
  966. $aSet = [
  967. 'agent_id' => (int)($aCtx['agent_id'] ?? 0),
  968. 'profile_id' => (int)($aCtx['profile_id'] ?? 0),
  969. 'thread_id' => (string)($aCtx['thread_id'] ?? ''),
  970. 'op' => $sOp,
  971. 'table_name' => $sTable,
  972. 'pk_name' => $sPk,
  973. 'pk_value' => $sPkValue,
  974. 'sql_text' => $sSql,
  975. 'before_json' => $mixedBefore ? json_encode($mixedBefore, JSON_UNESCAPED_UNICODE) : null,
  976. 'after_json' => $mixedAfter ? json_encode($mixedAfter, JSON_UNESCAPED_UNICODE) : null,
  977. 'added' => time(),
  978. ];
  979. if ($oDb->isFieldExists('sys_agents_sql_log', 'undone'))
  980. $aSet['undone'] = 0;
  981. $oDb->query("INSERT INTO `sys_agents_sql_log` SET " . $oDb->arrayToSQL($aSet));
  982. }
  983. }
  984. /** @} */