Explorar el Código

AI agents: agent_create tool, activity history, agents admin block for NEO (#5705)

* AI agents: agent_create tool, activity history, agents admin block for NEO

* Studio agents: don't return echoJson()'s output in performActionActivity

echoJson() prints the response and returns nothing, so returning its value
was a php:S3699 bug, the one issue failing this PR's SonarCloud reliability
rating. The popup response is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix deployment

* fix deployment

* fix deployment

* agent_create: act only as operator or Robot profiles, clip UTF-8 safely (#5717)

- An agent could be given any member's profile: profile=<id>, a full-name
  match, or a catalog search over every bx_persons_data row. Studio's own
  agent form only offers profiles of operator accounts
  (get_options_agents_profile). A message agent on a member's profile
  answers that member's messages in their name, and any agent with
  comments_add posts as them. Profiles now have to belong to an operator
  account or to the Robot account (sys_profile_bot), where profile_new
  creates them. The catalog lists and searches only those, so it no
  longer returns member names either.
- _clip() cut with substr(), splitting UTF-8 text mid-character (a
  Cyrillic title clipped to 255 bytes was invalid UTF-8). It now uses
  mb_strcut(): at most the same number of bytes, never half a letter.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix arguments order

* fix new profile author

* there is no sys_agents_profile setting

* get rid of hardcoded modules aliases list

* dry_run can describe the wrong change, ex: UPDATE t SET title = 'old where new' WHERE id = 12 keeps only title = 'old.

* fix hardcoded RUssian strings

* Checking for class_exists isn't needed

* remove strange comments

* localized buttons, restrict to use installed modules only

* Fix Cyrillic chars

---------

Co-authored-by: Andrey Yasko <ay@una.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Aleksandr Trofimov <at@unacms.com>
RomanL hace 3 semanas
padre
commit
062f05b3ec

+ 17 - 12
.github/workflows/ci.yml

@@ -91,6 +91,9 @@ jobs:
     needs: test
     if: github.event_name == 'pull_request' && github.event.action != 'closed'
     runs-on: ubuntu-latest
+    concurrency:
+      group: railway-pr-${{ github.event.pull_request.number }}
+      cancel-in-progress: true
     env:
       RAILWAY_API_TOKEN: ${{ secrets.RAILWAY_API_TOKEN }}
       RAILWAY_PROJECT_ID: ${{ secrets.RAILWAY_PROJECT_ID }}
@@ -110,7 +113,7 @@ jobs:
         unzip -q packages/*.zip -d /tmp/una-pkg
         pkg_dir=$(find /tmp/una-pkg -mindepth 1 -maxdepth 1 -type d | head -n 1)
         cp -a "$pkg_dir"/. deploy/
-        cp scripts/railway/Dockerfile scripts/railway/entrypoint.sh scripts/railway/railway.toml deploy/
+        cp scripts/railway/Dockerfile scripts/railway/entrypoint.sh scripts/railway/railway.toml scripts/railway/.railwayignore deploy/
         chmod +x deploy/entrypoint.sh
 
     - name: Install Railway CLI
@@ -126,26 +129,28 @@ jobs:
         fi
         base="${RAILWAY_BASE_ENVIRONMENT:-production}"
         railway link --project "$RAILWAY_PROJECT_ID" --environment "$base" --json
-        railway environment new "$RAILWAY_PR_ENVIRONMENT" || true
+        environments=$(railway environment list --json)
+        if printf '%s\n' "$environments" | jq -e --arg name "$RAILWAY_PR_ENVIRONMENT" '.environments[] | select(.name == $name)' >/dev/null; then
+          echo "Reusing Railway environment $RAILWAY_PR_ENVIRONMENT"
+        else
+          railway environment new "$RAILWAY_PR_ENVIRONMENT" --duplicate "$base" --json
+        fi
         railway link --project "$RAILWAY_PROJECT_ID" --environment "$RAILWAY_PR_ENVIRONMENT" --json
-        railway add --image mariadb:11 --service mariadb --json \
-          --variables MARIADB_ROOT_PASSWORD=una \
-          --variables MARIADB_DATABASE=una \
-          --variables MARIADB_USER=una \
-          --variables MARIADB_PASSWORD=una \
-          || true
-        railway add --service una --json || true
+        railway variable set --service mariadb --environment "$RAILWAY_PR_ENVIRONMENT" --skip-deploys \
+          MARIADB_ROOT_PASSWORD=una \
+          MARIADB_DATABASE=una \
+          MARIADB_USER=una \
+          MARIADB_PASSWORD=una
         railway variable set --service una --environment "$RAILWAY_PR_ENVIRONMENT" --skip-deploys \
           MYSQLHOST=mariadb.railway.internal \
           MYSQLPORT=3306 \
           MYSQLDATABASE=una \
           MYSQLUSER=una \
-          MYSQLPASSWORD=una \
-          || true
+          MYSQLPASSWORD=una
         railway domain --service una --environment "$RAILWAY_PR_ENVIRONMENT" || true
 
     - name: Deploy artefact
-      run: railway up ./deploy --path-as-root --no-gitignore --ci --service una --environment "$RAILWAY_PR_ENVIRONMENT" --project "$RAILWAY_PROJECT_ID"
+      run: railway up ./deploy --path-as-root --no-gitignore --verbose --ci --service una --environment "$RAILWAY_PR_ENVIRONMENT" --project "$RAILWAY_PROJECT_ID"
 
     - name: Comment preview URL on PR
       env:

+ 1107 - 0
inc/classes/BxDolAIToolAgentCreate.php

@@ -0,0 +1,1107 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * Operator tool: create / update / catalog Studio agents (sys_agents_agents).
+ * mysql_write_safe cannot touch that table — use this instead.
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+use NeuronAI\Tools\PropertyType;
+use NeuronAI\Tools\ToolProperty;
+
+class BxDolAIToolAgentCreate extends BxDolAITool
+{
+    protected const TRIGGERS = ['alert', 'scheduler', 'webhook', 'manual', 'agent', 'message', 'form-input'];
+    protected const PRESETS = ['comment_reply', 'manual', 'message', 'custom'];
+    protected const COMMENT_ACTIONS = ['commentPost', 'comment_added', 'commentAdded', 'cmt_post'];
+    protected const FORBIDDEN_TOOL_TYPES = ['mysql_write'];
+    protected const CLIP = 20000;
+    protected const PROFILE_NAME_MAX = 40;
+    protected const VERSION = 4;
+
+    /** @var int[]|null see _allowedAccounts() */
+    protected $_aAllowedAccounts = null;
+
+    /** @var array<string,string>|null lowercase label => sys_modules.name */
+    protected $_aModuleNames = null;
+
+    protected const COMMENT_TOOLS = ['comments_get', 'comments_add', 'comment_get', 'content_get'];
+
+    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.";
+
+    public function __construct()
+    {
+        parent::__construct(
+            'agent_create',
+            'Create or update a Studio agent (sys_agents_agents). Do NOT use mysql_write on that table. '
+            . 'Never ask the user for a numeric profile_id. action=catalog returns profiles and profile_buttons — call chat_buttons with exactly those labels. '
+            . '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. '
+            . 'preset=comment_reply for “reply to every comment”: trigger=alert, async=1, comments_* tools. '
+            . 'Pass module as one name from installed content modules: ' . $this->getContentModules() . '. Or alert as unit:action (for example bx_forum:commentPost). '
+            . '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.'
+        );
+    }
+
+    protected function properties(): array
+    {
+        return [
+            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),
+            new ToolProperty('preset', PropertyType::STRING, 'comment_reply | manual | message | custom. comment_reply = alert agent that replies to comments.', false),
+            new ToolProperty('title', PropertyType::STRING, 'Human title. Required for create.', false),
+            new ToolProperty('name', PropertyType::STRING, 'Unique machine name. Auto from title if empty.', false),
+            new ToolProperty('description', PropertyType::STRING, 'Operator notes.', false),
+            new ToolProperty('prompt_system', PropertyType::STRING, 'System / role prompt. Required for create. This is what the new agent will follow.', false),
+            new ToolProperty('prompt_steps', PropertyType::STRING, 'Optional steps prompt.', false),
+            new ToolProperty('prompt_output', PropertyType::STRING, 'Optional output prompt.', false),
+            new ToolProperty('prompt_tools', PropertyType::STRING, 'Optional extra tool rules. comment_reply appends loop-prevention rules.', false),
+            new ToolProperty('trigger', PropertyType::STRING, 'alert | scheduler | webhook | manual | agent | message | form-input. Default from preset (comment_reply → alert).', false),
+            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),
+            new ToolProperty('module', PropertyType::STRING, 'Installed module: module from installed modules list.', false),
+            new ToolProperty('tools', PropertyType::STRING, 'CSV of tool types or ids (comments_add,comments_get). comment_reply fills comments_* if empty.', false),
+            new ToolProperty('model_id', PropertyType::INTEGER, 'Chat model id. Default = operator agent / sys_agents_model.', false),
+            new ToolProperty('profile_id', PropertyType::INTEGER, 'Numeric profile id. Prefer profile (name) or profile_new. Do not ask the user for this number.', false),
+            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),
+            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),
+            new ToolProperty('vector_store_id', PropertyType::INTEGER, 'Knowledge store id. Default = operator / first active store / 0.', false),
+            new ToolProperty('async', PropertyType::INTEGER, '1 = delayed (use for alert comment replies so the post is not blocked). Default 1 for alert.', false),
+            new ToolProperty('active', PropertyType::INTEGER, '1 = run immediately. Default 1.', false),
+            new ToolProperty('scheduler_cron', PropertyType::STRING, 'CRON string for trigger=scheduler.', false),
+            new ToolProperty('webhook_key', PropertyType::STRING, 'Bearer key for trigger=webhook.', false),
+            new ToolProperty('message_profile_id', PropertyType::INTEGER, 'Accepted sender for trigger=message. 0 = any.', false),
+            new ToolProperty('form_object', PropertyType::STRING, 'Form object for trigger=form-input.', false),
+            new ToolProperty('form_input', PropertyType::STRING, 'Form input name for trigger=form-input.', false),
+            new ToolProperty('agent_id', PropertyType::INTEGER, 'Existing sys_agents_agents.id for update.', false),
+            new ToolProperty('query', PropertyType::STRING, 'catalog filter: alert unit/action, tool type, agent name.', false),
+            new ToolProperty('dry_run', PropertyType::BOOLEAN, 'If true, validate and return the row without writing.', false),
+        ];
+    }
+
+    public function __invoke(
+        $action = 'catalog',
+        $preset = '',
+        $title = '',
+        $name = '',
+        $description = '',
+        $prompt_system = '',
+        $prompt_steps = '',
+        $prompt_output = '',
+        $prompt_tools = '',
+        $trigger = '',
+        $alert = '',
+        $module = '',
+        $tools = '',
+        $model_id = 0,
+        $profile_id = 0,
+        $profile = '',
+        $profile_new = '',
+        $vector_store_id = 0,
+        $async = null,
+        $active = 1,
+        $scheduler_cron = '',
+        $webhook_key = '',
+        $message_profile_id = 0,
+        $form_object = '',
+        $form_input = '',
+        $agent_id = 0,
+        $query = '',
+        $dry_run = false
+    ): array {
+        $sAction = strtolower(trim((string)$action));
+        if ($sAction === 'list')
+            $sAction = 'catalog';
+        if (!in_array($sAction, ['catalog', 'create', 'update'], true))
+            return ['ok' => 0, 'error' => 'action must be catalog, create, or update'];
+
+        try {
+            if ($sAction === 'catalog')
+                return $this->_catalog((string)$query, (string)$preset, (string)$module);
+            if ($sAction === 'update')
+                return $this->_update((int)$agent_id, [
+                    'title' => $title,
+                    'description' => $description,
+                    'prompt_system' => $prompt_system,
+                    'prompt_steps' => $prompt_steps,
+                    'prompt_output' => $prompt_output,
+                    'prompt_tools' => $prompt_tools,
+                    'tools' => $tools,
+                    'alert' => $alert,
+                    'module' => $module,
+                    'async' => $async,
+                    'active' => $active,
+                    'preset' => $preset,
+                    'profile' => $profile,
+                    'profile_id' => $profile_id,
+                    'profile_new' => $profile_new,
+                ], $this->_isDry($dry_run));
+
+            return $this->_create([
+                'preset' => $preset,
+                'title' => $title,
+                'name' => $name,
+                'description' => $description,
+                'prompt_system' => $prompt_system,
+                'prompt_steps' => $prompt_steps,
+                'prompt_output' => $prompt_output,
+                'prompt_tools' => $prompt_tools,
+                'trigger' => $trigger,
+                'alert' => $alert,
+                'module' => $module,
+                'tools' => $tools,
+                'model_id' => $model_id,
+                'profile_id' => $profile_id,
+                'vector_store_id' => $vector_store_id,
+                'async' => $async,
+                'active' => $active,
+                'scheduler_cron' => $scheduler_cron,
+                'webhook_key' => $webhook_key,
+                'message_profile_id' => $message_profile_id,
+                'form_object' => $form_object,
+                'form_input' => $form_input,
+                'profile' => $profile,
+                'profile_new' => $profile_new,
+            ], $this->_isDry($dry_run));
+        } catch (Throwable $o) {
+            return ['ok' => 0, 'error' => $o->getMessage()];
+        }
+    }
+
+    protected function _isDry($dry_run): bool
+    {
+        return $dry_run === true || $dry_run === 1 || $dry_run === '1' || $dry_run === 'true';
+    }
+
+    protected function _catalog(string $sQuery, string $sPreset, string $sModule): array
+    {
+        $oDb = BxDolDb::getInstance();
+        $sQ = mb_strtolower(trim($sQuery));
+        $sMod = $this->_resolveModule($sModule !== '' ? $sModule : $sQuery);
+
+        $aAlerts = [];
+        if ($oDb->isTableExists('sys_alerts_log')) {
+            $aRows = $oDb->getAll("SELECT `unit`, `action`, `counter_24h` FROM `sys_alerts_log` ORDER BY `counter_24h` DESC, `unit`, `action` LIMIT 400");
+            foreach ($aRows as $aRow) {
+                $sKey = $aRow['unit'] . ':' . $aRow['action'];
+                $sHay = mb_strtolower($sKey . ' ' . ($aRow['unit'] ?? '') . ' ' . ($aRow['action'] ?? ''));
+                $bComment = in_array($aRow['action'], self::COMMENT_ACTIONS, true);
+                if ($sQ !== '' && strpos($sHay, $sQ) === false && !($sMod && $aRow['unit'] === $sMod) && !($sPreset === 'comment_reply' && $bComment))
+                    continue;
+                if ($sPreset === 'comment_reply' && $sQ === '' && $sModule === '' && !$bComment)
+                    continue;
+                if ($sMod && $aRow['unit'] !== $sMod && strpos($sHay, $sQ) === false)
+                    continue;
+                $aAlerts[] = [
+                    'alert' => $sKey,
+                    'fired_24h' => (int)$aRow['counter_24h'],
+                    'desc' => method_exists('BxDolAiQuery', 'getAlertDesc') ? (string)BxDolAiQuery::getAlertDesc($sKey) : '',
+                ];
+                if (count($aAlerts) >= 40)
+                    break;
+            }
+        }
+
+        $aTools = [];
+        $aToolRows = $oDb->getAll("SELECT `id`, `type`, `title`, `active` FROM `sys_agents_tools` ORDER BY `type` ASC");
+        foreach ($aToolRows as $aRow) {
+            $sHay = mb_strtolower($aRow['id'] . ' ' . $aRow['type'] . ' ' . $aRow['title']);
+            if ($sQ !== '' && strpos($sHay, $sQ) === false && $sPreset !== 'comment_reply')
+                continue;
+            if ($sPreset === 'comment_reply' && $sQ === '' && !in_array($aRow['type'], self::COMMENT_TOOLS, true))
+                continue;
+            $aTools[] = [
+                'id' => (int)$aRow['id'],
+                'type' => $aRow['type'],
+                'title' => $aRow['title'],
+                'active' => (int)$aRow['active'],
+            ];
+        }
+
+        $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");
+        $aStores = $oDb->isTableExists('sys_agents_vector_store')
+            ? $oDb->getAll("SELECT `id`, `title`, `type`, `active` FROM `sys_agents_vector_store` WHERE `active` = 1 ORDER BY `title` ASC LIMIT 20")
+            : [];
+        $aAgents = $oDb->getAll("SELECT `id`, `name`, `title`, `trigger`, `alert`, `active`, `profile_id`, `model_id` FROM `sys_agents_agents` ORDER BY `id` DESC LIMIT 30");
+        if ($sQ !== '') {
+            $aAgents = array_values(array_filter($aAgents, function ($aRow) use ($sQ) {
+                $sHay = mb_strtolower(($aRow['name'] ?? '') . ' ' . ($aRow['title'] ?? '') . ' ' . ($aRow['alert'] ?? '') . ' ' . ($aRow['trigger'] ?? ''));
+                return strpos($sHay, $sQ) !== false;
+            }));
+        }
+
+        $aDefaults = $this->_defaults();
+        $aProfiles = $this->_listProfiles('');
+        $sSuggest = $this->_suggestProfileName($sPreset);
+
+        return [
+            'ok' => 1,
+            'version' => self::VERSION,
+            'action' => 'catalog',
+            'profiles' => $aProfiles,
+            'profiles_count' => count($aProfiles),
+            'suggested_new_name' => $sSuggest,
+            'profile_buttons' => $this->_profileButtons($aProfiles, $sSuggest),
+            '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.',
+            'defaults' => $aDefaults,
+            'presets' => self::PRESETS,
+            '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.',
+            'alerts' => $aAlerts,
+            'tools' => $aTools,
+            'models' => $aModels,
+            'vector_stores' => $aStores,
+            'agents' => $aAgents,
+        ];
+    }
+
+    protected function _create(array $aIn, bool $bDry): array
+    {
+        $sPreset = $this->_preset($aIn['preset'] ?? '');
+        $sTitle = trim((string)($aIn['title'] ?? ''));
+        if ($sTitle === '')
+            return ['ok' => 0, 'error' => 'title is required'];
+
+        $sPrompt = $this->_clip((string)($aIn['prompt_system'] ?? ''));
+        if (trim($sPrompt) === '')
+            return ['ok' => 0, 'error' => 'prompt_system is required — this is what the new agent will do'];
+
+        $sTrigger = strtolower(trim((string)($aIn['trigger'] ?? '')));
+        if ($sTrigger === '')
+            $sTrigger = $this->_triggerForPreset($sPreset);
+        if (!in_array($sTrigger, self::TRIGGERS, true))
+            return ['ok' => 0, 'error' => 'unknown trigger. Use: ' . implode(', ', self::TRIGGERS)];
+
+        $aDefaults = $this->_defaults();
+        $iModel = (int)($aIn['model_id'] ?? 0) ?: (int)$aDefaults['model_id'];
+        $sProfileIn = trim((string)($aIn['profile'] ?? ''));
+        $sProfileNewIn = trim((string)($aIn['profile_new'] ?? ''));
+        if ($sProfileIn === '' && $sProfileNewIn === '' && (int)($aIn['profile_id'] ?? 0) <= 0) {
+            // never fall back to the operator's own profile silently — the user must pick a name or a new one
+            $aProfiles = $this->_listProfiles('');
+            $sSuggest = $this->_suggestProfileName($sPreset);
+            return [
+                'ok' => 0,
+                'error' => _t('_sys_agents_profile_required_err', _t('_sys_agents_profile_btn_new', $sSuggest)),
+                'profiles' => $aProfiles,
+                'suggested_new_name' => $sSuggest,
+                'profile_buttons' => $this->_profileButtons($aProfiles, $sSuggest),
+            ];
+        }
+
+        $aProf = $this->_resolveProfile($sProfileIn, $aIn['profile_id'] ?? 0, $sProfileNewIn, $bDry);
+        if (!empty($aProf['error']))
+            return ['ok' => 0, 'error' => $aProf['error'], 'candidates' => $aProf['candidates'] ?? []];
+        $iProfile = (int)($aProf['profile_id'] ?? 0);
+        $iStore = (int)($aIn['vector_store_id'] ?? 0) ?: (int)$aDefaults['vector_store_id'];
+
+        if ($iModel <= 0)
+            return ['ok' => 0, 'error' => 'no chat model. Pass model_id or activate a chatllm model.'];
+        if ($iProfile <= 0 && empty($aProf['dry_create']))
+            return ['ok' => 0, 'error' => 'profile could not be resolved. Pass profile=<name from catalog.profiles> or profile_new=<name>.'];
+
+        $oDb = BxDolDb::getInstance();
+        if (!$oDb->getRow("SELECT `id` FROM `sys_agents_models` WHERE `id` = :id AND `active` = 1 LIMIT 1", ['id' => $iModel]))
+            return ['ok' => 0, 'error' => "model_id {$iModel} is missing or inactive"];
+
+        $sAlert = '';
+        $aAlertPick = null;
+        if ($sTrigger === 'alert') {
+            $aAlertPick = $this->_resolveAlert((string)($aIn['alert'] ?? ''), (string)($aIn['module'] ?? ''), $sPreset);
+            if (!empty($aAlertPick['error']))
+                return ['ok' => 0, 'error' => $aAlertPick['error'], 'candidates' => $aAlertPick['candidates'] ?? []];
+            $sAlert = $aAlertPick['alert'];
+        }
+
+        $sToolsCsv = $this->_resolveToolsCsv((string)($aIn['tools'] ?? ''), $sPreset);
+        if (is_array($sToolsCsv) && isset($sToolsCsv['error']))
+            return $sToolsCsv;
+
+        if ($sTrigger === 'message') {
+            $aDup = $oDb->getRow("SELECT `id`, `name` FROM `sys_agents_agents` WHERE `trigger` = 'message' AND `profile_id` = :p LIMIT 1", ['p' => $iProfile]);
+            if ($aDup)
+                return ['ok' => 0, 'error' => "profile_id {$iProfile} already has message-agent {$aDup['id']} ({$aDup['name']}). One message-agent per profile."];
+        }
+
+        if ($sTrigger === 'scheduler' && trim((string)($aIn['scheduler_cron'] ?? '')) === '')
+            return ['ok' => 0, 'error' => 'scheduler_cron is required for trigger=scheduler'];
+        if ($sTrigger === 'webhook' && trim((string)($aIn['webhook_key'] ?? '')) === '')
+            return ['ok' => 0, 'error' => 'webhook_key is required for trigger=webhook'];
+        if ($sTrigger === 'form-input' && (trim((string)($aIn['form_object'] ?? '')) === '' || trim((string)($aIn['form_input'] ?? '')) === ''))
+            return ['ok' => 0, 'error' => 'form_object and form_input are required for trigger=form-input'];
+
+        $iAsync = $aIn['async'];
+        if ($iAsync === null || $iAsync === '')
+            $iAsync = in_array($sTrigger, ['alert', 'webhook', 'message'], true) ? 1 : 0;
+        $iAsync = (int)$iAsync ? 1 : 0;
+        if (in_array($sTrigger, ['manual', 'scheduler'], true))
+            $iAsync = 0;
+
+        $iActive = isset($aIn['active']) && $aIn['active'] !== '' && $aIn['active'] !== null
+            ? ((int)$aIn['active'] ? 1 : 0)
+            : 1;
+
+        $sName = $this->_uniqueName((string)($aIn['name'] ?? ''), $sTitle);
+        $sPromptTools = $this->_clip((string)($aIn['prompt_tools'] ?? ''));
+        if ($sPreset === 'comment_reply') {
+            $sPromptTools = trim($sPromptTools . "\n\n" . self::COMMENT_PROMPT_TOOLS);
+        }
+
+        $sPromptSteps = $this->_clip((string)($aIn['prompt_steps'] ?? ''));
+        if ($sPreset === 'comment_reply' && $sPromptSteps === '') {
+            $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.";
+        }
+
+        $aRow = [
+            'title' => $this->_clip($sTitle, 255),
+            'name' => $sName,
+            'description' => $this->_clip((string)($aIn['description'] ?? ''), 2000),
+            'icon' => '',
+            'model_id' => $iModel,
+            'profile_id' => $iProfile,
+            'acl_levels' => 0,
+            'trigger' => $sTrigger,
+            'async' => $iAsync,
+            'alert' => $sAlert,
+            'scheduler_cron' => trim((string)($aIn['scheduler_cron'] ?? '')),
+            'webhook_key' => trim((string)($aIn['webhook_key'] ?? '')),
+            'message_profile_id' => (int)($aIn['message_profile_id'] ?? 0),
+            'form_object' => trim((string)($aIn['form_object'] ?? '')),
+            'form_input' => trim((string)($aIn['form_input'] ?? '')),
+            'max_turns' => 40,
+            'chat_ttl_min' => 0,
+            'max_input_chars' => 2000,
+            'max_tokens' => 1024,
+            'max_sessions_per_hour' => 3,
+            'max_sessions_per_day' => 10,
+            'vector_store_id' => $iStore,
+            'chat_history_context' => $sTrigger === 'alert' ? 0 : 50000,
+            'tools' => $sToolsCsv,
+            'tools_max_run' => $sPreset === 'comment_reply' ? 8 : 10,
+            'prompt_system' => $sPrompt,
+            'prompt_steps' => $sPromptSteps,
+            'prompt_output' => $this->_clip((string)($aIn['prompt_output'] ?? '')),
+            'prompt_tools' => $sPromptTools,
+            'added' => time(),
+            'active' => $iActive,
+        ];
+
+        $aRow = $this->_onlyExistingColumns($oDb, 'sys_agents_agents', $aRow);
+
+        $aOut = [
+            'ok' => 1,
+            'action' => $bDry ? 'dry_run' : 'create',
+            'preset' => $sPreset,
+            'studio' => '/studio/agents.php?page=agents',
+            'row' => [
+                'name' => $aRow['name'],
+                'title' => $aRow['title'],
+                'trigger' => $aRow['trigger'],
+                'alert' => $aRow['alert'] ?? '',
+                'async' => (int)($aRow['async'] ?? 0),
+                'active' => (int)$aRow['active'],
+                'model_id' => (int)$aRow['model_id'],
+                'profile_id' => (int)$aRow['profile_id'],
+                'profile_name' => $aProf['name'] ?? '',
+                'profile_created' => (int)($aProf['created'] ?? 0),
+                'vector_store_id' => (int)($aRow['vector_store_id'] ?? 0),
+                'tools' => $aRow['tools'],
+                'prompt_system_chars' => strlen($aRow['prompt_system']),
+            ],
+        ];
+        if (!empty($aAlertPick['warning']))
+            $aOut['warning'] = $aAlertPick['warning'];
+
+        if ($bDry)
+            return $aOut;
+
+        $mixed = $oDb->query("INSERT INTO `sys_agents_agents` SET " . $oDb->arrayToSQL($aRow));
+        if ($mixed === false)
+            return ['ok' => 0, 'error' => 'INSERT failed'];
+
+        $iId = (int)$oDb->lastId();
+        $oDb->cleanCache('sys_agents_with_alert');
+        $this->_logSql('insert', 'sys_agents_agents', 'id', (string)$iId, 'INSERT agent ' . $aRow['name'], null, $aRow);
+
+        $aOut['id'] = $iId;
+        $aOut['row']['id'] = $iId;
+        $aOut['next'] = $iActive
+            ? 'Agent is active. Test the event (post a comment) or open Studio Agents grid.'
+            : 'Created inactive. action=update agent_id=' . $iId . ' active=1 to turn on.';
+        return $aOut;
+    }
+
+    protected function _update(int $iId, array $aIn, bool $bDry): array
+    {
+        if ($iId <= 0)
+            return ['ok' => 0, 'error' => 'agent_id is required for update'];
+
+        $oDb = BxDolDb::getInstance();
+        $aBefore = $oDb->getRow("SELECT * FROM `sys_agents_agents` WHERE `id` = :id LIMIT 1", ['id' => $iId]);
+        if (!$aBefore)
+            return ['ok' => 0, 'error' => "agent {$iId} not found"];
+
+        $aSet = [];
+        foreach (['title', 'description', 'prompt_system', 'prompt_steps', 'prompt_output', 'prompt_tools'] as $sF) {
+            if (isset($aIn[$sF]) && $aIn[$sF] !== '' && $aIn[$sF] !== null)
+                $aSet[$sF] = $this->_clip((string)$aIn[$sF], $sF === 'title' ? 255 : self::CLIP);
+        }
+
+        if (isset($aIn['tools']) && $aIn['tools'] !== '' && $aIn['tools'] !== null) {
+            $sCsv = $this->_resolveToolsCsv((string)$aIn['tools'], $this->_preset($aIn['preset'] ?? ''));
+            if (is_array($sCsv) && isset($sCsv['error']))
+                return $sCsv;
+            $aSet['tools'] = $sCsv;
+        }
+
+        if ((isset($aIn['alert']) && $aIn['alert'] !== '') || (isset($aIn['module']) && $aIn['module'] !== '')) {
+            $aAlertPick = $this->_resolveAlert((string)($aIn['alert'] ?? ''), (string)($aIn['module'] ?? ''), $this->_preset($aIn['preset'] ?? 'comment_reply'));
+            if (!empty($aAlertPick['error']))
+                return ['ok' => 0, 'error' => $aAlertPick['error'], 'candidates' => $aAlertPick['candidates'] ?? []];
+            $aSet['alert'] = $aAlertPick['alert'];
+            $aSet['trigger'] = 'alert';
+        }
+
+        if ($aIn['async'] !== null && $aIn['async'] !== '')
+            $aSet['async'] = (int)$aIn['async'] ? 1 : 0;
+        if ($aIn['active'] !== null && $aIn['active'] !== '')
+            $aSet['active'] = (int)$aIn['active'] ? 1 : 0;
+
+        $bWantProfile = (isset($aIn['profile']) && $aIn['profile'] !== '' && $aIn['profile'] !== null)
+            || (isset($aIn['profile_new']) && $aIn['profile_new'] !== '' && $aIn['profile_new'] !== null)
+            || !empty($aIn['profile_id']);
+        $aProf = ['name' => '', 'created' => 0];
+        if ($bWantProfile) {
+            $aProf = $this->_resolveProfile($aIn['profile'] ?? '', $aIn['profile_id'] ?? 0, $aIn['profile_new'] ?? '', $bDry);
+            if (!empty($aProf['error']))
+                return ['ok' => 0, 'error' => $aProf['error'], 'candidates' => $aProf['candidates'] ?? []];
+            if (!empty($aProf['profile_id']))
+                $aSet['profile_id'] = (int)$aProf['profile_id'];
+        }
+
+        if (!$aSet)
+            return ['ok' => 0, 'error' => 'nothing to update'];
+
+        $aSet = $this->_onlyExistingColumns($oDb, 'sys_agents_agents', $aSet);
+
+        if ($bDry) {
+            return ['ok' => 1, 'action' => 'dry_run', 'id' => $iId, 'set' => array_keys($aSet)];
+        }
+
+        $b = $oDb->query("UPDATE `sys_agents_agents` SET " . $oDb->arrayToSQL($aSet) . " WHERE `id` = :id", ['id' => $iId]);
+        if ($b === false)
+            return ['ok' => 0, 'error' => 'UPDATE failed'];
+
+        $oDb->cleanCache('sys_agents_with_alert');
+        $aAfter = $oDb->getRow("SELECT * FROM `sys_agents_agents` WHERE `id` = :id LIMIT 1", ['id' => $iId]);
+        $this->_logSql('update', 'sys_agents_agents', 'id', (string)$iId, 'UPDATE agent ' . $iId, $aBefore, $aAfter ?: null);
+
+        return [
+            'ok' => 1,
+            'action' => 'update',
+            'id' => $iId,
+            'name' => $aAfter['name'] ?? $aBefore['name'],
+            'changed' => array_keys($aSet),
+            'active' => (int)($aAfter['active'] ?? 0),
+            'profile_id' => (int)($aAfter['profile_id'] ?? $aBefore['profile_id'] ?? 0),
+            'profile_name' => $aProf['name'] ?? '',
+            'profile_created' => (int)($aProf['created'] ?? 0),
+            'studio' => '/studio/agents.php?page=agents',
+        ];
+    }
+
+    protected function _preset($s): string
+    {
+        $s = strtolower(trim((string)$s));
+        if ($s === '' || $s === 'custom')
+            return 'custom';
+        if (in_array($s, self::PRESETS, true))
+            return $s;
+        return 'custom';
+    }
+
+    protected function _triggerForPreset(string $sPreset): string
+    {
+        if ($sPreset === 'comment_reply')
+            return 'alert';
+        if ($sPreset === 'message')
+            return 'message';
+        if ($sPreset === 'manual')
+            return 'manual';
+        return 'alert';
+    }
+
+    protected function _resolveModule(string $s): string
+    {
+        $s = trim($s);
+        if ($s === '')
+            return '';
+        $sLow = mb_strtolower($s);
+        $aNames = $this->_moduleNames();
+        if (isset($aNames[$sLow]))
+            return $aNames[$sLow];
+        if (preg_match('/^[a-z0-9_]+$/i', $s))
+            return $s;
+        return '';
+    }
+
+    /**
+     * Labels that mean a module: machine name, uri, install title, and the current-language title from _t().
+     * Enabled modules claim a label before disabled ones. A name or uri is never overwritten by a title.
+     *
+     * @return array<string,string>
+     */
+    protected function _moduleNames(): array
+    {
+        if ($this->_aModuleNames !== null)
+            return $this->_aModuleNames;
+
+        $this->_aModuleNames = [];
+        $oDb = BxDolDb::getInstance();
+        $aRows = $oDb->getAll("SELECT `name`, `uri`, `title` FROM `sys_modules` WHERE `name` <> '' ORDER BY `enabled` DESC, `name` ASC");
+        foreach ($aRows as $aRow)
+            $this->_rememberModule((string)$aRow['name'], (string)$aRow['name']);
+        foreach ($aRows as $aRow)
+            $this->_rememberModule((string)$aRow['uri'], (string)$aRow['name']);
+        foreach ($aRows as $aRow) {
+            $sName = (string)$aRow['name'];
+            $this->_rememberModule((string)$aRow['title'], $sName);
+            $sKey = BxDolModule::getTitleKey((string)$aRow['uri']);
+            $sTitle = _t($sKey);
+            if ($sTitle !== $sKey)
+                $this->_rememberModule($sTitle, $sName);
+        }
+
+        return $this->_aModuleNames;
+    }
+
+    protected function _rememberModule(string $sLabel, string $sName): void
+    {
+        $sLabel = mb_strtolower(trim($sLabel));
+        if ($sLabel === '' || isset($this->_aModuleNames[$sLabel]))
+            return;
+        $this->_aModuleNames[$sLabel] = $sName;
+    }
+
+    protected function _resolveAlert(string $sAlert, string $sModule, string $sPreset): array
+    {
+        $sAlert = trim($sAlert);
+        $sAlert = str_replace([' ', '/', '|'], ':', $sAlert);
+        $sAlert = preg_replace('/:+/', ':', $sAlert);
+
+        if ($sAlert !== '' && preg_match('/^[a-zA-Z0-9_]+:[a-zA-Z0-9_]+$/', $sAlert)) {
+            $aWarn = $this->_alertInLog($sAlert) ? '' : 'Alert is not in sys_alerts_log yet (never fired). It will run once that unit:action happens.';
+            return ['alert' => $sAlert, 'warning' => $aWarn];
+        }
+
+        $sMod = $this->_resolveModule($sModule !== '' ? $sModule : $sAlert);
+        $aCandidates = $this->_commentAlerts($sMod);
+
+        if ($sPreset === 'comment_reply' || $sMod !== '') {
+            if (count($aCandidates) === 1)
+                return ['alert' => $aCandidates[0]['alert']];
+            if (count($aCandidates) > 1)
+                return ['error' => 'Several comment alerts match. Pass alert=unit:action.', 'candidates' => $aCandidates];
+            if ($sMod !== '')
+                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' => []];
+        }
+
+        return ['error' => 'Pass alert as unit:action (bx_forum:commentPost) or module=discussions / bx_forum. action=catalog preset=comment_reply to list.', 'candidates' => $aCandidates];
+    }
+
+    protected function _commentAlerts(string $sMod): array
+    {
+        $oDb = BxDolDb::getInstance();
+        if (!$oDb->isTableExists('sys_alerts_log'))
+            return [];
+
+        $aParams = [];
+        $sWhere = "`action` IN (" . $oDb->implode_escape(self::COMMENT_ACTIONS) . ")";
+        if ($sMod !== '') {
+            $sWhere .= " AND `unit` = :u";
+            $aParams['u'] = $sMod;
+        }
+        $aRows = $oDb->getAll("SELECT `unit`, `action`, `counter_24h` FROM `sys_alerts_log` WHERE {$sWhere} ORDER BY `counter_24h` DESC LIMIT 20", $aParams);
+        $aOut = [];
+        foreach ($aRows as $aRow) {
+            $aOut[] = [
+                'alert' => $aRow['unit'] . ':' . $aRow['action'],
+                'fired_24h' => (int)$aRow['counter_24h'],
+            ];
+        }
+        return $aOut;
+    }
+
+    protected function _alertInLog(string $sAlert): bool
+    {
+        $oDb = BxDolDb::getInstance();
+        if (!$oDb->isTableExists('sys_alerts_log'))
+            return false;
+        $a = explode(':', $sAlert, 2);
+        $s = $oDb->getOne("SELECT CONCAT(`unit`, ':', `action`) FROM `sys_alerts_log` WHERE `unit` = :u AND `action` = :a LIMIT 1", [
+            'u' => $a[0] ?? '',
+            'a' => $a[1] ?? '',
+        ]);
+        return (bool)$s;
+    }
+
+    protected function _listProfiles(string $sQuery = ''): array
+    {
+        $aOut = [];
+        $aSeen = [];
+        $oDb = BxDolDb::getInstance();
+
+        // each source is isolated: one failing query must not empty the whole list
+        $aDefaults = $this->_defaults();
+        $this->_pushProfile($aOut, $aSeen, (int)$aDefaults['profile_id'], '', 'default');
+
+        try {
+            // profiles of the preset Robot account (sys_profile_bot) go first — new profiles are created there
+            $iBotAccount = $this->_botAccount();
+            if ($iBotAccount > 0) {
+                $aRows = $oDb->getAll("SELECT `id` FROM `sys_profiles` WHERE `account_id` = :a AND `status` = 'active' ORDER BY `id` ASC LIMIT 50", ['a' => $iBotAccount]);
+                foreach ($aRows as $aRow)
+                    $this->_pushProfile($aOut, $aSeen, (int)$aRow['id'], '', 'robot');
+            }
+        } catch (Throwable $o) {
+            bx_log('sys_agents', 'agent_create profiles (robot account): ' . $o->getMessage(), BX_LOG_WARN);
+        }
+
+        try {
+            $iOpAccount = (int)$oDb->getOne("SELECT `account_id` FROM `sys_profiles` WHERE `id` = :id LIMIT 1", ['id' => (int)$aDefaults['profile_id']]);
+            if ($iOpAccount > 0) {
+                $aRows = $oDb->getAll("SELECT `id` FROM `sys_profiles` WHERE `account_id` = :a AND `status` = 'active' ORDER BY `id` ASC LIMIT 50", ['a' => $iOpAccount]);
+                foreach ($aRows as $aRow)
+                    $this->_pushProfile($aOut, $aSeen, (int)$aRow['id'], '', 'operator');
+            }
+        } catch (Throwable $o) {
+            bx_log('sys_agents', 'agent_create profiles (account): ' . $o->getMessage(), BX_LOG_WARN);
+        }
+
+        try {
+            if (function_exists('bx_srv')) {
+                $aOpts = bx_srv('system', 'get_options_agents_profile', [false], 'TemplServices');
+                if (is_array($aOpts)) {
+                    foreach ($aOpts as $aOpt)
+                        $this->_pushProfile($aOut, $aSeen, (int)($aOpt['key'] ?? 0), (string)($aOpt['value'] ?? ''), 'operator');
+                }
+            }
+        } catch (Throwable $o) {
+            bx_log('sys_agents', 'agent_create profiles (service): ' . $o->getMessage(), BX_LOG_WARN);
+        }
+
+        try {
+            $iRoleAdmin = defined('BX_DOL_ROLE_ADMIN') ? (int)BX_DOL_ROLE_ADMIN : 2;
+            $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", [
+                'r' => $iRoleAdmin,
+            ]);
+            foreach ($aRows as $aRow)
+                $this->_pushProfile($aOut, $aSeen, (int)$aRow['id'], '', 'operator');
+        } catch (Throwable $o) {
+            bx_log('sys_agents', 'agent_create profiles (admins): ' . $o->getMessage(), BX_LOG_WARN);
+        }
+
+        try {
+            $aUsed = $oDb->getColumn("SELECT DISTINCT `profile_id` FROM `sys_agents_agents` WHERE `profile_id` > 0 ORDER BY `profile_id` DESC LIMIT 30");
+            foreach ($aUsed as $iId)
+                $this->_pushProfile($aOut, $aSeen, (int)$iId, '', 'agent');
+        } catch (Throwable $o) {
+            bx_log('sys_agents', 'agent_create profiles (agents): ' . $o->getMessage(), BX_LOG_WARN);
+        }
+
+        $sQ = trim($sQuery);
+        if ($sQ !== '' && mb_strlen($sQ) <= 80 && $oDb->isTableExists('bx_persons_data')) {
+            $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", [
+                'q' => '%' . $sQ . '%',
+            ]);
+            foreach ($aRows as $aRow)
+                $this->_pushProfile($aOut, $aSeen, (int)$aRow['id'], (string)$aRow['fullname'], 'search');
+        }
+
+        return $aOut;
+    }
+
+    protected function _profileButtons(array $aProfiles, string $sSuggest): array
+    {
+        $aOut = [];
+        foreach ($aProfiles as $aP) {
+            $s = (string)($aP['name'] ?? '');
+            if ($s !== '' && !in_array($s, $aOut, true))
+                $aOut[] = $s;
+        }
+        $aOut[] = _t('_sys_agents_profile_btn_new', $sSuggest);
+        return $aOut;
+    }
+
+    protected function _suggestProfileName(string $sPreset): string
+    {
+        return _t($this->_preset($sPreset) === 'comment_reply' ? '_sys_agents_profile_name_commenter' : '_sys_agents_profile_name_bot');
+    }
+
+    /**
+     * Accounts whose profiles an agent may act as: the operators' (the profiles
+     * Studio offers, get_options_agents_profile) and the Robot account's, where
+     * profile_new creates them. Never a member's: an agent on a member's profile
+     * would post, and as a message agent answer their messages, in their name.
+     *
+     * @return int[]
+     */
+    protected function _allowedAccounts(): array
+    {
+        if ($this->_aAllowedAccounts === null) {
+            $aIds = array_map('intval', (array)BxDolAccountQuery::getInstance()->getOperators());
+            $aIds[] = $this->_botAccount();
+            $this->_aAllowedAccounts = array_values(array_unique(array_filter($aIds)));
+        }
+        return $this->_aAllowedAccounts;
+    }
+
+    protected function _isAllowedAgentProfile(int $iProfileId): bool
+    {
+        if ($iProfileId <= 0)
+            return false;
+        $iAccount = (int)BxDolDb::getInstance()->getOne("SELECT `account_id` FROM `sys_profiles` WHERE `id` = :id LIMIT 1", ['id' => $iProfileId]);
+        return $iAccount > 0 && in_array($iAccount, $this->_allowedAccounts(), true);
+    }
+
+    /** `AND p.account_id IN (...)` for a query over `sys_profiles` aliased `p`. */
+    protected function _allowedAccountsSql(): string
+    {
+        $aIds = $this->_allowedAccounts();
+        return ' AND p.`account_id` IN (' . ($aIds ? implode(',', $aIds) : '0') . ')';
+    }
+
+    protected function _pushProfile(array &$aOut, array &$aSeen, int $iId, string $sFallback, string $sSource): void
+    {
+        if ($iId <= 0 || isset($aSeen[$iId]) || !$this->_isAllowedAgentProfile($iId))
+            return;
+        try {
+            $sName = $this->_profileDisplayName($iId, $sFallback);
+        } catch (Throwable $o) {
+            $sName = $sFallback;
+        }
+        if ($sName === '')
+            $sName = '#' . $iId;
+        $aOut[] = ['id' => $iId, 'name' => $sName, 'source' => $sSource];
+        $aSeen[$iId] = 1;
+    }
+
+    protected function _shortDisplayName(string $s): array
+    {
+        $s = trim(preg_replace('/\s+/u', ' ', $s));
+        $sPrefix = trim(_t('_sys_agents_profile_btn_new', ''));
+        if ($sPrefix !== '' && $sPrefix !== '_sys_agents_profile_btn_new')
+            $s = trim((string)preg_replace('/^' . preg_quote($sPrefix, '/') . '\s*/iu', '', $s));
+        if ($s === '')
+            return ['error' => 'profile name is empty'];
+
+        $iWords = count(preg_split('/\s+/u', $s, -1, PREG_SPLIT_NO_EMPTY));
+        if (mb_strlen($s) > self::PROFILE_NAME_MAX || $iWords > 3 || preg_match('/[\r\n.!?—–;:]/u', $s)) {
+            return ['error' => _t('_sys_agents_profile_new_err', _t('_sys_agents_profile_name_commenter'), _t('_sys_agents_profile_name_bot'))];
+        }
+
+        return ['name' => $s];
+    }
+
+    protected function _profileDisplayName(int $iId, string $sFallback = ''): string
+    {
+        $o = BxDolProfile::getInstance($iId);
+        if ($o)
+            return $o->getDisplayName();
+        if ($sFallback !== '')
+            return $sFallback;
+        return '#' . $iId;
+    }
+
+    protected function _resolveProfile($sProfile, $mixedId, $sNew, bool $bDry): array
+    {
+        $sNew = trim((string)$sNew);
+        if ($sNew !== '')
+            return $this->_createPersonProfile($sNew, $bDry);
+
+        $sProfile = trim((string)$sProfile);
+        $iId = (int)$mixedId;
+        if ($sProfile !== '' && preg_match('/^\d+$/', $sProfile))
+            $iId = (int)$sProfile;
+
+        if ($iId > 0) {
+            $o = BxDolProfile::getInstance($iId);
+            if (!$o)
+                return ['error' => "profile {$iId} not found"];
+            if (!$this->_isAllowedAgentProfile($iId))
+                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>."];
+            return ['profile_id' => $iId, 'name' => $o->getDisplayName(), 'created' => 0];
+        }
+
+        if ($sProfile === '')
+            return ['profile_id' => 0, 'name' => '', 'created' => 0];
+
+        $sProfile = ltrim($sProfile, '@');
+        $aHits = [];
+        foreach ($this->_listProfiles($sProfile) as $aP) {
+            if (mb_strtolower((string)$aP['name']) === mb_strtolower($sProfile) || (string)$aP['id'] === $sProfile)
+                $aHits[(int)$aP['id']] = $aP;
+        }
+
+        $oDb = BxDolDb::getInstance();
+        if ($oDb->isTableExists('bx_persons_data')) {
+            $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", [
+                'n' => $sProfile,
+            ]);
+            foreach ($aRows as $aRow)
+                $aHits[(int)$aRow['id']] = ['id' => (int)$aRow['id'], 'name' => $aRow['fullname']];
+
+            if (!$aHits) {
+                $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", [
+                    'n' => '%' . $sProfile . '%',
+                ]);
+                foreach ($aRows as $aRow)
+                    $aHits[(int)$aRow['id']] = ['id' => (int)$aRow['id'], 'name' => $aRow['fullname']];
+            }
+        }
+
+        $aList = array_values($aHits);
+        if (count($aList) === 1)
+            return ['profile_id' => (int)$aList[0]['id'], 'name' => $aList[0]['name'], 'created' => 0];
+        if (count($aList) > 1)
+            return ['error' => 'Several profiles match. Pass profile=exact name from catalog.profiles.', 'candidates' => $aList];
+
+        return ['error' => 'No profile named «' . $sProfile . '». Pick from catalog.profiles or pass profile_new=' . $sProfile . '.'];
+    }
+
+    protected function _createPersonProfile(string $sName, bool $bDry): array
+    {
+        $aName = $this->_shortDisplayName($sName);
+        if (!empty($aName['error']))
+            return $aName;
+        $sName = $aName['name'];
+
+        $oDb = BxDolDb::getInstance();
+        if (!$oDb->isTableExists('bx_persons_data'))
+            return ['error' => 'bx_persons_data is missing — cannot create a Persons profile'];
+
+        // new bot profiles always live under the preset Robot account (sys_profile_bot), not under the operator.
+        // author is that Robot profile's id; sys_profiles.account_id is the account that owns it.
+        $aBot = $this->_botIdentity();
+        $iAuthor = (int)$aBot['profile_id'];
+        $iAccount = (int)$aBot['account_id'];
+        if ($iAuthor <= 0 || $iAccount <= 0)
+            return ['error' => 'Robot account not found: set sys_profile_bot (Studio → Settings → Bot profile), then retry.'];
+
+        $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", [
+            'a' => $iAccount,
+            'n' => $sName,
+        ]);
+        if ($aExist)
+            return ['profile_id' => (int)$aExist['id'], 'name' => $sName, 'created' => 0];
+
+        if ($bDry)
+            return ['profile_id' => 0, 'name' => $sName, 'created' => 1, 'dry_create' => 1];
+
+        $iNow = time();
+        $iPublic = defined('BX_DOL_PG_ALL') ? BX_DOL_PG_ALL : 3;
+        $aPerson = $this->_onlyExistingColumns($oDb, 'bx_persons_data', [
+            'author' => $iAuthor,
+            'added' => $iNow,
+            'changed' => $iNow,
+            'fullname' => $sName,
+            'last_name' => '',
+            'allow_view_to' => $iPublic,
+            'allow_post_to' => $iPublic,
+            'allow_contact_to' => $iPublic,
+        ]);
+        $mixed = $oDb->query("INSERT INTO `bx_persons_data` SET " . $oDb->arrayToSQL($aPerson));
+        if ($mixed === false)
+            return ['error' => 'failed to insert bx_persons_data'];
+
+        $iContent = (int)$oDb->lastId();
+        $this->_logSql('insert', 'bx_persons_data', 'id', (string)$iContent, 'INSERT person ' . $sName, null, array_merge($aPerson, ['id' => $iContent]));
+
+        $iAction = defined('BX_PROFILE_ACTION_AUTO') ? BX_PROFILE_ACTION_AUTO : 0;
+        $iProfile = (int)BxDolProfile::add($iAction, $iAccount, $iContent, 'active', 'bx_persons');
+        if ($iProfile <= 0)
+            return ['error' => 'person row created but sys_profiles insert failed', 'content_id' => $iContent];
+
+        $this->_logSql('insert', 'sys_profiles', 'id', (string)$iProfile, 'INSERT profile ' . $sName, null, [
+            'id' => $iProfile,
+            'account_id' => $iAccount,
+            'content_id' => $iContent,
+            'type' => 'bx_persons',
+            'status' => 'active',
+        ]);
+
+        return ['profile_id' => $iProfile, 'name' => $sName, 'created' => 1];
+    }
+
+    protected function _resolveToolsCsv(string $sTools, string $sPreset)
+    {
+        $oDb = BxDolDb::getInstance();
+        $aWanted = [];
+        $sTools = trim($sTools);
+        if ($sTools !== '') {
+            foreach (preg_split('/[,\s]+/', $sTools) as $sTok) {
+                $sTok = trim($sTok);
+                if ($sTok === '')
+                    continue;
+                $aWanted[] = $sTok;
+            }
+        } else if ($sPreset === 'comment_reply') {
+            $aWanted = self::COMMENT_TOOLS;
+        }
+
+        if (!$aWanted)
+            return '';
+
+        $aIds = [];
+        $aMissing = [];
+        foreach ($aWanted as $sTok) {
+            if (preg_match('/^\d+$/', $sTok)) {
+                $aRow = $oDb->getRow("SELECT `id`, `type`, `active` FROM `sys_agents_tools` WHERE `id` = :id LIMIT 1", ['id' => (int)$sTok]);
+            } else {
+                $aRow = $oDb->getRow("SELECT `id`, `type`, `active` FROM `sys_agents_tools` WHERE `type` = :t ORDER BY `active` DESC, `id` ASC LIMIT 1", ['t' => $sTok]);
+            }
+            if (!$aRow) {
+                $aMissing[] = $sTok;
+                continue;
+            }
+            if (in_array($aRow['type'], self::FORBIDDEN_TOOL_TYPES, true))
+                return ['ok' => 0, 'error' => "Refusing stock mysql_write on a created agent. Use mysql_write_safe if you really need writes."];
+            $aIds[(int)$aRow['id']] = (int)$aRow['id'];
+        }
+        if ($aMissing)
+            return ['ok' => 0, 'error' => 'Unknown tools: ' . implode(', ', $aMissing) . '. action=catalog to list types.'];
+
+        return implode(',', array_values($aIds));
+    }
+
+    protected function _uniqueName(string $sName, string $sTitle): string
+    {
+        $s = $sName !== '' ? $sName : $sTitle;
+        $s = strtolower($s);
+        $s = preg_replace('/[^a-z0-9_]+/', '_', $s);
+        $s = trim($s, '_');
+        if ($s === '')
+            $s = 'agent';
+        if (strlen($s) > 80)
+            $s = substr($s, 0, 80);
+
+        $oDb = BxDolDb::getInstance();
+        $sBase = $s;
+        $i = 2;
+        while ($oDb->getOne("SELECT `id` FROM `sys_agents_agents` WHERE `name` = :n LIMIT 1", ['n' => $s])) {
+            $s = $sBase . '_' . $i;
+            $i++;
+            if ($i > 99)
+                $s = $sBase . '_' . bin2hex(random_bytes(3));
+        }
+        return $s;
+    }
+
+    protected function _defaults(): array
+    {
+        $oDb = BxDolDb::getInstance();
+        $aOp = null;
+        $aCtx = BxDolAIToolMysqlWrite::currentLogContext();
+        $iFromChat = (int)($aCtx['agent_id'] ?? 0);
+        if ($iFromChat > 0)
+            $aOp = $oDb->getRow("SELECT `id`, `model_id`, `profile_id`, `vector_store_id` FROM `sys_agents_agents` WHERE `id` = :id LIMIT 1", ['id' => $iFromChat]);
+        if (!$aOp) {
+            $iOpt = (int)getParam('sys_agents_operator_agent');
+            if ($iOpt > 0)
+                $aOp = $oDb->getRow("SELECT `id`, `model_id`, `profile_id`, `vector_store_id` FROM `sys_agents_agents` WHERE `id` = :id LIMIT 1", ['id' => $iOpt]);
+        }
+
+        $iModel = (int)($aOp['model_id'] ?? 0) ?: (int)(getParam('sys_agents_model'));
+        $iProfile = (int)($aOp['profile_id'] ?? 0);
+        if ($iProfile <= 0)
+            $iProfile = (int)getParam('sys_profile_bot');
+        $iStore = (int)($aOp['vector_store_id'] ?? 0);
+        if ($iStore <= 0 && $oDb->isTableExists('sys_agents_vector_store'))
+            $iStore = (int)$oDb->getOne("SELECT `id` FROM `sys_agents_vector_store` WHERE `active` = 1 ORDER BY `id` ASC LIMIT 1");
+
+        return [
+            'model_id' => $iModel,
+            'profile_id' => $iProfile,
+            'vector_store_id' => $iStore,
+            'from_agent_id' => (int)($aOp['id'] ?? 0),
+        ];
+    }
+
+    /**
+     * Preset Robot profile and the account that owns it: sys_profile_bot, then the operator agent's profile.
+     *
+     * @return array{profile_id:int,account_id:int}
+     */
+    protected function _botIdentity(): array
+    {
+        $oDb = BxDolDb::getInstance();
+        $aCandidates = [
+            (int)getParam('sys_profile_bot'),
+            (int)($this->_defaults()['profile_id'] ?? 0),
+        ];
+        foreach ($aCandidates as $iProfile) {
+            if ($iProfile <= 0)
+                continue;
+            $iAccount = (int)$oDb->getOne("SELECT `account_id` FROM `sys_profiles` WHERE `id` = :id LIMIT 1", ['id' => $iProfile]);
+            if ($iAccount > 0)
+                return ['profile_id' => $iProfile, 'account_id' => $iAccount];
+        }
+        return ['profile_id' => 0, 'account_id' => 0];
+    }
+
+    /**
+     * Account of the preset Robot. See _botIdentity().
+     */
+    protected function _botAccount(): int
+    {
+        return (int)$this->_botIdentity()['account_id'];
+    }
+
+    protected function _onlyExistingColumns(BxDolDb $oDb, string $sTable, array $aRow): array
+    {
+        $aOut = [];
+        foreach ($aRow as $sK => $mixedV) {
+            if ($oDb->isFieldExists($sTable, $sK))
+                $aOut[$sK] = $mixedV;
+        }
+        return $aOut;
+    }
+
+    protected function _clip(string $s, int $iMax = 0): string
+    {
+        $iMax = $iMax ?: self::CLIP;
+        $s = trim($s);
+        if (strlen($s) <= $iMax)
+            return $s;
+        // at most $iMax bytes, never half a character: substr() split UTF-8 text mid-letter
+        return mb_strcut($s, 0, $iMax, 'UTF-8');
+    }
+
+    protected function _logSql(string $sOp, string $sTable, string $sPk, string $sPkValue, string $sSql, $mixedBefore, $mixedAfter): void
+    {
+        $oDb = BxDolDb::getInstance();
+        if (!$oDb->isTableExists('sys_agents_sql_log'))
+            return;
+
+        $aCtx = BxDolAIToolMysqlWrite::currentLogContext();
+        $aSet = [
+            'agent_id' => (int)($aCtx['agent_id'] ?? 0),
+            'profile_id' => (int)($aCtx['profile_id'] ?? 0),
+            'thread_id' => (string)($aCtx['thread_id'] ?? ''),
+            'op' => $sOp,
+            'table_name' => $sTable,
+            'pk_name' => $sPk,
+            'pk_value' => $sPkValue,
+            'sql_text' => $sSql,
+            'before_json' => $mixedBefore ? json_encode($mixedBefore, JSON_UNESCAPED_UNICODE) : null,
+            'after_json' => $mixedAfter ? json_encode($mixedAfter, JSON_UNESCAPED_UNICODE) : null,
+            'added' => time(),
+        ];
+        if ($oDb->isFieldExists('sys_agents_sql_log', 'undone'))
+            $aSet['undone'] = 0;
+
+        $oDb->query("INSERT INTO `sys_agents_sql_log` SET " . $oDb->arrayToSQL($aSet));
+    }
+}
+
+/** @} */

+ 8 - 2
inc/classes/BxDolAIToolChatButtons.php

@@ -16,18 +16,24 @@ class BxDolAIToolChatButtons extends BxDolAITool
 {
     public function __construct()
     {
+        $sYes = _t('_Yes');
+        $sNo = _t('_No');
+
         parent::__construct(
             'chat_buttons',
-            'Show clickable buttons under THIS assistant reply: Yes/No, suggested names, register/support links. Call once per turn, after the sentence the user should confirm. Do not write another assistant message after this tool. Do not say that buttons are ready.',
+            'Show clickable buttons under THIS assistant reply: ' . $sYes . '/' . $sNo . ', suggested names, register/support links. Call once per turn, after the sentence the user should confirm. Write every label in the site language. A yes/no choice uses exactly these labels: "' . $sYes . '" and "' . $sNo . '". Do not write another assistant message after this tool. Do not say that buttons are ready.',
         );
     }
 
     protected function properties(): array
     {
+        $sYes = _t('_Yes');
+        $sNo = _t('_No');
+
         return [
             new ArrayProperty(
                 name: 'buttons',
-                description: 'Buttons under this reply. reply = visitor tap sends that label as the next message; link = an https URL (site pages, help, registration). Example: [{"type":"reply","label":"Yes"},{"type":"reply","label":"No"}]',
+                description: 'Buttons under this reply. reply = visitor tap sends that label as the next message; link = an https URL (site pages, help, registration). Yes/no labels are exactly "' . $sYes . '" and "' . $sNo . '" (site language). Example: [{"type":"reply","label":"' . $sYes . '"},{"type":"reply","label":"' . $sNo . '"}]',
                 required: true,
                 items: new ObjectProperty(
                     name: 'button',

+ 1 - 1
inc/classes/BxDolAIToolChatTitle.php

@@ -35,7 +35,7 @@ class BxDolAIToolChatTitle extends BxDolAITool
     protected function properties(): array
     {
         return [
-            new ToolProperty('title', PropertyType::STRING, 'Conversation title, 3-6 words, no quotes, no trailing period. Example: "Счётчик комментариев поста 291"', true),
+            new ToolProperty('title', PropertyType::STRING, 'Conversation title, 3-6 words, no quotes, no trailing period. Example: "Comment counter for post 291"', true),
         ];
     }
 

+ 0 - 2
inc/classes/BxDolAIToolMockup.php

@@ -80,8 +80,6 @@ class BxDolAIToolMockup extends BxDolAITool
 
     public static function currentBlockId()
     {
-        if (!class_exists('BxDolPage'))
-            return 0;
         $aProc = BxDolPage::getBlockProcessing();
         return (!empty($aProc['id'])) ? (int)$aProc['id'] : 0;
     }

+ 215 - 8
inc/classes/BxDolAIToolMysqlWrite.php

@@ -29,7 +29,8 @@ class BxDolAIToolMysqlWrite extends BxDolAITool
     {
         parent::__construct(
             'mysql_write',
-            'INSERT or UPDATE one row. DELETE/DROP/TRUNCATE/ALTER are forbidden. UPDATE WHERE must identify exactly one row (prefer primary key: WHERE id = N). Snapshots are stored automatically. Prefer mysql_schema and mysql_select first.',
+            'INSERT or UPDATE one row. DELETE/DROP/TRUNCATE/ALTER are forbidden. UPDATE WHERE must identify exactly one row (prefer primary key: WHERE id = N). Snapshots are stored automatically. Prefer mysql_schema and mysql_select first. '
+            . 'Call with dry_run=true first: it returns a plain-language summary (table, row, field: old → new) — show that to the person, never the SQL.',
         );
     }
 
@@ -42,14 +43,21 @@ class BxDolAIToolMysqlWrite extends BxDolAITool
                 description: 'Single INSERT or UPDATE. UPDATE needs WHERE that matches one row, e.g. WHERE `id` = 12. Table.column and extra AND conditions are ok. No DELETE. No multiple statements.',
                 required: true
             ),
+            new ToolProperty(
+                name: 'dry_run',
+                type: PropertyType::BOOLEAN,
+                description: 'true = validate and return summary / changes (field: old → new) without writing. Use it before asking the person to confirm.',
+                required: false
+            ),
         ];
     }
 
-    public function __invoke(string $query): array
+    public function __invoke(string $query, $dry_run = false): array
     {
         $oDb = BxDolDb::getInstance();
         $sSql = $this->_normalize($query);
         $sBare = $this->_stripLiterals($sSql);
+        $bDry = $dry_run === true || $dry_run === 1 || $dry_run === '1' || $dry_run === 'true';
 
         $this->_assertSafe($sBare);
 
@@ -75,6 +83,21 @@ class BxDolAIToolMysqlWrite extends BxDolAITool
             $sBefore = json_encode($aBefore, JSON_UNESCAPED_UNICODE);
         }
 
+        if ($bDry) {
+            $aAssign = $this->_parseAssignments($sSql, $sOp);
+            $aChanges = $this->_changes($sOp === 'update' ? ($aBefore ?? []) : null, $aAssign, $sOp === 'update');
+            return [
+                'ok' => 1,
+                'dry_run' => 1,
+                'op' => $sOp,
+                'table' => $sTable,
+                'pk' => $sPk,
+                'pk_value' => $sPkValue,
+                'changes' => $aChanges,
+                'summary' => $this->_summary($sOp, $sTable, $sPkValue, $aChanges),
+            ];
+        }
+
         $mixedRes = $oDb->query($sSql);
         if ($mixedRes === false)
             throw new Exception('Query failed.');
@@ -90,6 +113,11 @@ class BxDolAIToolMysqlWrite extends BxDolAITool
         $aAfter = $this->_fetchRow($oDb, $sTable, $sPk, $sPkValue);
         $iLogId = $this->_log($sOp, $sTable, $sPk, $sPkValue, $sSql, $sBefore, $aAfter ? json_encode($aAfter, JSON_UNESCAPED_UNICODE) : null);
 
+        // exact diff from the snapshots (the dry run only had the parsed SET clause)
+        $aChanges = $sOp === 'update'
+            ? $this->_changes($aBefore ?? [], $aAfter ?: [], true)
+            : $this->_changes(null, $this->_parseAssignments($sSql, $sOp), false);
+
         return [
             'ok' => 1,
             'op' => $sOp,
@@ -98,9 +126,181 @@ class BxDolAIToolMysqlWrite extends BxDolAITool
             'pk_value' => $sPkValue,
             'log_id' => $iLogId,
             'affected' => is_numeric($mixedRes) ? (int)$mixedRes : 1,
+            'changes' => $aChanges,
+            'summary' => $this->_summary($sOp, $sTable, $sPkValue, $aChanges),
         ];
     }
 
+    /**
+     * column => literal from the SET clause (UPDATE / INSERT ... SET) or the
+     * (columns) VALUES (...) pair. Expressions that are not plain literals
+     * (NOW(), col + 1) are kept as their SQL text.
+     *
+     * SET and WHERE are located on a copy with quoted literals blanked
+     * ($sBare, same length as $sSql) so a value cannot end the clause.
+     */
+    protected function _parseAssignments(string $sSql, string $sOp): array
+    {
+        $aOut = [];
+        $sBare = $this->_maskLiterals($sSql, true);
+        if (preg_match('/\bset\b/i', $sBare, $aSet, PREG_OFFSET_CAPTURE)) {
+            $iFrom = $aSet[0][1] + strlen($aSet[0][0]);
+            $sClause = substr($sSql, $iFrom);
+            if (preg_match('/\bwhere\b/i', substr($sBare, $iFrom), $aWhere, PREG_OFFSET_CAPTURE))
+                $sClause = substr($sSql, $iFrom, $aWhere[0][1]);
+            foreach ($this->_splitTopLevel($sClause) as $sPair) {
+                if (preg_match('/^\s*(?:`?[a-zA-Z0-9_]+`?\s*\.\s*)?`?([a-zA-Z0-9_]+)`?\s*=\s*(.+)$/s', trim($sPair), $aP))
+                    $aOut[$aP[1]] = $this->_literal(trim($aP[2]));
+            }
+            return $aOut;
+        }
+        if ($sOp === 'insert' && preg_match('/^insert\s+into\s+`?[a-zA-Z0-9_]+`?\s*\((.+?)\)\s*values\s*\((.+)\)\s*$/is', $sSql, $aM)) {
+            $aCols = array_map(fn($c) => trim($c, " `\t\n\r"), $this->_splitTopLevel($aM[1]));
+            $aVals = $this->_splitTopLevel($aM[2]);
+            foreach ($aCols as $i => $sCol) {
+                if ($sCol !== '' && array_key_exists($i, $aVals))
+                    $aOut[$sCol] = $this->_literal(trim($aVals[$i]));
+            }
+        }
+        return $aOut;
+    }
+
+    /**
+     * Copy of $s with the inside of quoted literals replaced by spaces.
+     * Length and quote marks stay, so keyword offsets still point into $s.
+     * Quote rules match _splitTopLevel() (backslash and doubled quotes).
+     * $bIdents also blanks `identifiers`, so a column named where/set is not a clause boundary.
+     */
+    protected function _maskLiterals(string $s, bool $bIdents = false): string
+    {
+        $iLen = strlen($s);
+        $sQuote = '';
+        for ($i = 0; $i < $iLen; $i++) {
+            $c = $s[$i];
+            if ($sQuote !== '') {
+                if ($sQuote !== '`' && $c === '\\' && $i + 1 < $iLen) {
+                    $s[$i] = ' ';
+                    $s[++$i] = ' ';
+                } elseif ($c === $sQuote) {
+                    if ($i + 1 < $iLen && $s[$i + 1] === $sQuote) {
+                        $s[$i] = ' ';
+                        $s[++$i] = ' ';
+                    } else {
+                        $sQuote = '';
+                    }
+                } else {
+                    $s[$i] = ' ';
+                }
+                continue;
+            }
+            if ($c === "'" || $c === '"' || ($bIdents && $c === '`'))
+                $sQuote = $c;
+        }
+        return $s;
+    }
+
+    /** Split on commas that are outside quotes and parentheses. */
+    protected function _splitTopLevel(string $s): array
+    {
+        $aOut = [];
+        $sCur = '';
+        $iDepth = 0;
+        $sQuote = '';
+        $iLen = strlen($s);
+        for ($i = 0; $i < $iLen; $i++) {
+            $c = $s[$i];
+            if ($sQuote !== '') {
+                $sCur .= $c;
+                if ($c === '\\' && $i + 1 < $iLen) {
+                    $sCur .= $s[++$i];
+                } elseif ($c === $sQuote) {
+                    if ($i + 1 < $iLen && $s[$i + 1] === $sQuote)
+                        $sCur .= $s[++$i];
+                    else
+                        $sQuote = '';
+                }
+                continue;
+            }
+            if ($c === "'" || $c === '"') {
+                $sQuote = $c;
+                $sCur .= $c;
+            } elseif ($c === '(') {
+                $iDepth++;
+                $sCur .= $c;
+            } elseif ($c === ')') {
+                $iDepth--;
+                $sCur .= $c;
+            } elseif ($c === ',' && $iDepth === 0) {
+                $aOut[] = $sCur;
+                $sCur = '';
+            } else {
+                $sCur .= $c;
+            }
+        }
+        if (trim($sCur) !== '')
+            $aOut[] = $sCur;
+        return $aOut;
+    }
+
+    /** SQL literal → PHP value; anything else stays as the expression text. */
+    protected function _literal(string $s)
+    {
+        if (preg_match('/^null$/i', $s))
+            return null;
+        if (preg_match('/^-?\d+(\.\d+)?$/', $s))
+            return $s + 0;
+        $iLen = strlen($s);
+        if ($iLen >= 2 && ($s[0] === "'" || $s[0] === '"') && $s[$iLen - 1] === $s[0]) {
+            $q = $s[0];
+            $sInner = substr($s, 1, -1);
+            $sInner = str_replace($q . $q, $q, $sInner);
+            return stripcslashes($sInner);
+        }
+        return $s;
+    }
+
+    /**
+     * [{field, before, after}] — for an update only the fields whose value
+     * actually changes; for an insert every given field (before = null).
+     */
+    protected function _changes(?array $aBefore, array $aAfter, bool $bDiff): array
+    {
+        $aOut = [];
+        foreach ($aAfter as $sField => $mixedAfter) {
+            $mixedBefore = $aBefore[$sField] ?? null;
+            if ($bDiff && (string)$mixedBefore === (string)$mixedAfter)
+                continue;
+            $aOut[] = [
+                'field' => $sField,
+                'before' => $bDiff ? $this->_short($mixedBefore) : null,
+                'after' => $this->_short($mixedAfter),
+            ];
+        }
+        return $aOut;
+    }
+
+    protected function _short($mixed): ?string
+    {
+        if ($mixed === null)
+            return null;
+        $s = trim(strip_tags((string)$mixed));
+        $s = preg_replace('/\s+/u', ' ', $s);
+        return mb_strlen($s) > 120 ? mb_substr($s, 0, 119) . '…' : $s;
+    }
+
+    /** "sys_menu_items #12: active: 0 → 1; title: «Old» → «New»" */
+    protected function _summary(string $sOp, string $sTable, string $sPkValue, array $aChanges): string
+    {
+        $fQ = fn($v) => $v === null || $v === '' ? '—' : (is_numeric($v) ? (string)$v : '«' . $v . '»');
+        $aParts = [];
+        foreach ($aChanges as $aC)
+            $aParts[] = $aC['field'] . ': ' . ($sOp === 'update' ? $fQ($aC['before']) . ' → ' : '') . $fQ($aC['after']);
+        $sHead = $sTable . ($sPkValue !== '' ? ' #' . $sPkValue : '');
+        if (!$aParts)
+            return $sHead . ($sOp === 'update' ? ': no changes' : ': new row');
+        return ($sOp === 'insert' ? 'new row in ' : '') . $sHead . ': ' . implode('; ', $aParts);
+    }
+
     protected function _normalize(string $s): string
     {
         $s = preg_replace('~/\*.*?\*/~s', ' ', $s);
@@ -162,12 +362,18 @@ class BxDolAIToolMysqlWrite extends BxDolAITool
 
     protected function _wherePkValue(string $sSql, string $sPk, BxDolDb $oDb, string $sTable): string
     {
-        if (!preg_match('/\bwhere\b(.+)$/is', $sSql, $aM))
+        $sBare = $this->_maskLiterals($sSql, true);
+        if (!preg_match('/\bwhere\b/i', $sBare, $aWhere, PREG_OFFSET_CAPTURE))
             throw new Exception('UPDATE requires WHERE primary_key = value.');
 
-        $sWhere = trim($aM[1]);
-        $sWhere = preg_replace('/\s+(limit|order\s+by|offset)\b.*$/is', '', $sWhere);
-        $sWhere = trim($sWhere, " \t\n\r;");
+        $sWhere = substr($sSql, $aWhere[0][1] + strlen($aWhere[0][0]));
+        $sWhereBare = $this->_maskLiterals($sWhere, true);
+        $iCut = strlen($sWhere);
+        if (preg_match('/\s+(?:limit|offset)\b/i', $sWhereBare, $aTail, PREG_OFFSET_CAPTURE) && $aTail[0][1] < $iCut)
+            $iCut = $aTail[0][1];
+        if (preg_match('/\s+order\s+by\b/i', $sWhereBare, $aOrder, PREG_OFFSET_CAPTURE) && $aOrder[0][1] < $iCut)
+            $iCut = $aOrder[0][1];
+        $sWhere = trim(substr($sWhere, 0, $iCut), " \t\n\r;");
         if ($sWhere === '')
             throw new Exception('UPDATE requires WHERE primary_key = value.');
 
@@ -178,8 +384,9 @@ class BxDolAIToolMysqlWrite extends BxDolAITool
         $sPkRe = preg_quote($sPk, '/');
         $sTableRe = preg_quote($sTable, '/');
         $sIdent = '(?:`?' . $sTableRe . '`?\s*\.\s*)?`?' . $sPkRe . '`?';
-        if (preg_match('/(?:^|\band\s+)\s*\(?\s*' . $sIdent . '\s*=\s*(\d+|\'[^\']*\'|"[^"]*")/i', $sWhere, $aV))
-            return trim($aV[1], "\"'");
+        $sWhereMask = $this->_maskLiterals($sWhere);
+        if (preg_match('/(?:^|\band\s+)\s*\(?\s*' . $sIdent . '\s*=\s*(\d+|\'[^\']*\'|"[^"]*")/i', $sWhereMask, $aV, PREG_OFFSET_CAPTURE))
+            return trim(substr($sWhere, $aV[1][1], strlen($aV[1][0])), "\"'");
 
         $sTableSafe = str_replace('`', '', $sTable);
         $sPkSafe = str_replace('`', '', $sPk);

+ 489 - 0
inc/classes/BxDolAiActivity.php

@@ -0,0 +1,489 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * Human-readable activity of agents: what an alert / scheduler / webhook agent
+ * actually did (posted a comment, changed content, ran SQL...). One row per
+ * write tool call, stored in `sys_agents_activity`, written by
+ * BxDolAiActivityObserver and shown in Studio and in the App.
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAiActivity
+{
+    const TABLE = 'sys_agents_activity';
+    const SUMMARY_MAX = 300;
+    const DETAILS_MAX = 20000;
+
+    /**
+     * tool name → action key. Tools not listed here are read-only and are not recorded.
+     */
+    const TOOL_ACTIONS = [
+        'comments_add' => 'comment_add',
+        'comments_update' => 'comment_update',
+        'comments_delete' => 'comment_delete',
+        'content_add' => 'content_add',
+        'content_update' => 'content_update',
+        'content_delete' => 'content_delete',
+        'mysql_write' => 'db_write',
+        'mysql_write_safe' => 'db_write',
+        'mysql_undo' => 'db_undo',
+        'email_send' => 'email',
+        'polyglot' => 'lang',
+        'agent_create' => 'agent',
+        'mockup_upsert' => 'mockup',
+    ];
+
+    public static function isTableReady(): bool
+    {
+        static $bReady = null;
+        if ($bReady === null)
+            $bReady = BxDolDb::getInstance()->isTableExists(self::TABLE);
+        return $bReady;
+    }
+
+    /**
+     * Record one tool call of an agent. $mixedResult is whatever the tool returned
+     * (array or string); a "Tool ... failed:" string means the call threw.
+     */
+    public static function record(array $aAgent, array $aParams, string $sTool, array $aInputs, $mixedResult): int
+    {
+        if (!self::isTableReady())
+            return 0;
+
+        $sAction = self::TOOL_ACTIONS[$sTool] ?? '';
+        if ($sAction === '')
+            return 0;
+        if ($sTool === 'agent_create' && !in_array(strtolower((string)($aInputs['action'] ?? '')), ['create', 'update'], true))
+            return 0;
+        if ($sTool === 'mockup_upsert' && !empty($aInputs['dry_run']))
+            return 0;
+
+        $aResult = is_array($mixedResult) ? $mixedResult : [];
+        $sResultText = is_string($mixedResult) ? $mixedResult : '';
+        if ($sResultText !== '' && ($aDecoded = json_decode($sResultText, true)) && is_array($aDecoded))
+            $aResult = $aDecoded;
+
+        $bOk = true;
+        if (preg_match('/^Tool "[^"]*" failed:/', $sResultText))
+            $bOk = false;
+        elseif (isset($aResult['ok']) && !(int)$aResult['ok'])
+            $bOk = false;
+        elseif (isset($aResult['code']) && (int)$aResult['code'] >= 400)
+            $bOk = false;
+        elseif ($sTool === 'email_send' && $mixedResult === false)
+            $bOk = false;
+
+        $aRow = self::describeCall($sAction, $sTool, $aInputs, $aResult);
+
+        $sThreadId = '';
+        try {
+            $sThreadId = (string)BxDolAiChat::threadId($aAgent, $aParams);
+        } catch (Throwable $o) {
+        }
+
+        $aDetails = [
+            'inputs' => self::clipArray($aInputs),
+            'result' => $aResult ?: ($sResultText !== '' ? mb_substr($sResultText, 0, 2000) : null),
+        ];
+        $sDetails = json_encode($aDetails, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+        if ($sDetails === false)
+            $sDetails = '';
+        if (strlen($sDetails) > self::DETAILS_MAX)
+            $sDetails = substr($sDetails, 0, self::DETAILS_MAX);
+
+        $oDb = BxDolDb::getInstance();
+        $aSet = [
+            'agent_id' => (int)($aAgent['id'] ?? 0),
+            'profile_id' => (int)($aAgent['profile_id'] ?? 0),
+            'trigger' => (string)($aAgent['trigger'] ?? ''),
+            'thread_id' => $sThreadId,
+            'tool' => $sTool,
+            'action' => $sAction,
+            'unit' => (string)$aRow['unit'],
+            'object_id' => (int)$aRow['object_id'],
+            'object_url' => (string)$aRow['object_url'],
+            'object_title' => mb_substr((string)$aRow['object_title'], 0, 255),
+            'summary' => (string)$aRow['summary'],
+            'details' => $sDetails,
+            'ok' => $bOk ? 1 : 0,
+            'added' => time(),
+        ];
+        // the table may predate object_title
+        foreach (array_keys($aSet) as $sField) {
+            if (!$oDb->isFieldExists(self::TABLE, $sField))
+                unset($aSet[$sField]);
+        }
+        $oDb->query("INSERT INTO `" . self::TABLE . "` SET " . $oDb->arrayToSQL($aSet));
+        return (int)$oDb->lastId();
+    }
+
+    /**
+     * unit / object_id / object_url / summary for one call — what the row is about.
+     */
+    protected static function describeCall(string $sAction, string $sTool, array $aInputs, array $aResult): array
+    {
+        $sUnit = '';
+        $iObject = 0;
+        $sUrl = '';
+        $sTitle = '';
+        $sSummary = '';
+
+        $fText = function ($m, $iMax = self::SUMMARY_MAX) {
+            $s = trim(strip_tags(html_entity_decode((string)$m, ENT_QUOTES, 'UTF-8')));
+            $s = preg_replace('/\s+/u', ' ', $s);
+            return mb_strlen($s) > $iMax ? mb_substr($s, 0, $iMax - 1) . '…' : $s;
+        };
+
+        switch ($sAction) {
+            case 'comment_add':
+            case 'comment_update':
+            case 'comment_delete':
+                $sUnit = (string)($aInputs['module'] ?? '');
+                $iObject = (int)($aInputs['content_id'] ?? 0);
+                $iCmt = (int)($aResult['id'] ?? $aInputs['comment_id'] ?? 0);
+                $sSummary = $fText($aInputs['comment_text'] ?? '');
+                $aTarget = self::commentTarget($sUnit, $iObject, $iCmt);
+                $sTitle = $fText($aTarget['title'], 255);
+                $sUrl = $aTarget['url'];
+                break;
+
+            case 'content_add':
+            case 'content_update':
+            case 'content_delete':
+                $sUnit = (string)($aInputs['module'] ?? '');
+                $iObject = (int)($aInputs['content_id'] ?? $aResult['id'] ?? $aResult['content_id'] ?? 0);
+                $aData = is_array($aInputs['data'] ?? null) ? $aInputs['data'] : [];
+                // deleted content has no title to fetch any more — keep what the tool was given
+                $sTitle = $fText(self::contentTitle($sUnit, $iObject), 255);
+                if ($sTitle === '')
+                    $sTitle = $fText($aData['title'] ?? $aData['name'] ?? '', 255);
+                $sSummary = $fText($aData['text'] ?? $aData['description'] ?? '');
+                if ($sAction !== 'content_delete')
+                    $sUrl = self::contentUrl($sUnit, $iObject);
+                break;
+
+            case 'db_write':
+                $sQuery = trim((string)($aInputs['query'] ?? ''));
+                if (preg_match('/^\s*(insert\s+into|update)\s+`?([a-z0-9_]+)`?/i', $sQuery, $m))
+                    $sUnit = $m[2];
+                $iObject = (int)($aResult['id'] ?? $aResult['pk_value'] ?? $aResult['insert_id'] ?? 0);
+                $sSummary = $fText($sQuery);
+                // a row of a content module's data table is content: link it like content_update
+                $aTarget = self::contentByTable($sUnit, $iObject);
+                if ($aTarget) {
+                    $sTitle = $fText($aTarget['title'], 255);
+                    $sUrl = $aTarget['url'];
+                }
+                break;
+
+            case 'db_undo':
+                $sSummary = 'scope=' . (string)($aInputs['scope'] ?? 'last');
+                if (!empty($aResult['undone']) && is_array($aResult['undone']))
+                    $sSummary .= ', ' . count($aResult['undone']) . ' rows';
+                break;
+
+            case 'email':
+                $sUnit = (string)($aInputs['email'] ?? '');
+                $sSummary = $fText($aInputs['subject'] ?? '');
+                break;
+
+            case 'lang':
+                $sUnit = (string)($aInputs['key'] ?? '');
+                $sSummary = $fText($aInputs['string'] ?? '');
+                $sUrl = bx_absolute_url('studio/polyglot.php?page=keys');
+                break;
+
+            case 'agent':
+                $sUnit = strtolower((string)($aInputs['action'] ?? 'create'));
+                $iObject = (int)($aResult['id'] ?? $aInputs['agent_id'] ?? 0);
+                $sSummary = $fText($aInputs['title'] ?? ($aResult['name'] ?? ''));
+                $sUrl = !empty($aResult['studio']) ? bx_absolute_url((string)$aResult['studio']) : '';
+                break;
+
+            case 'mockup':
+                $sUnit = 'block';
+                $iObject = (int)($aInputs['block_id'] ?? 0);
+                $sSummary = $fText($aResult['summary'] ?? '');
+                $aBlock = self::pageBlock($iObject);
+                $sTitle = $fText($aBlock['title'] ?? '', 255);
+                $sUrl = (string)($aBlock['url'] ?? '');
+                break;
+        }
+
+        return ['unit' => $sUnit, 'object_id' => $iObject, 'object_url' => $sUrl, 'object_title' => $sTitle, 'summary' => $sSummary];
+    }
+
+    /**
+     * Rows for one agent, newest first, with the text a person reads.
+     */
+    public static function listForAgent(int $iAgentId, int $iStart = 0, int $iLimit = 50): array
+    {
+        if (!self::isTableReady() || $iAgentId <= 0)
+            return [];
+        $iLimit = max(1, min(200, $iLimit));
+        $iStart = max(0, $iStart);
+        $aRows = BxDolDb::getInstance()->getAll("SELECT * FROM `" . self::TABLE . "` WHERE `agent_id` = :a ORDER BY `id` DESC LIMIT {$iStart}, {$iLimit}", ['a' => $iAgentId]);
+        $aOut = [];
+        foreach ($aRows as $aRow)
+            $aOut[] = self::formatRow($aRow);
+        return $aOut;
+    }
+
+    /**
+     * agent_id → ['count' => n, 'last' => ts] for the agents grid.
+     */
+    public static function statsByAgent(): array
+    {
+        if (!self::isTableReady())
+            return [];
+        $aRows = BxDolDb::getInstance()->getAll("SELECT `agent_id`, COUNT(*) AS `count`, MAX(`added`) AS `last` FROM `" . self::TABLE . "` GROUP BY `agent_id`");
+        $aOut = [];
+        foreach ($aRows as $aRow)
+            $aOut[(int)$aRow['agent_id']] = ['count' => (int)$aRow['count'], 'last' => (int)$aRow['last']];
+        return $aOut;
+    }
+
+    public static function deleteForAgent(int $iAgentId): void
+    {
+        if (!self::isTableReady() || $iAgentId <= 0)
+            return;
+        BxDolDb::getInstance()->query("DELETE FROM `" . self::TABLE . "` WHERE `agent_id` = :a", ['a' => $iAgentId]);
+    }
+
+    /**
+     * @return array id, action, tool, ok, text (human sentence), summary, unit, object_id, url, added, added_formatted, thread_id
+     */
+    public static function formatRow(array $aRow): array
+    {
+        $iAdded = (int)($aRow['added'] ?? 0);
+        $aRow = self::resolveTarget($aRow);
+        return [
+            'id' => (int)($aRow['id'] ?? 0),
+            'action' => (string)($aRow['action'] ?? ''),
+            'tool' => (string)($aRow['tool'] ?? ''),
+            'ok' => (int)($aRow['ok'] ?? 1),
+            'text' => self::describe($aRow),
+            'summary' => (string)($aRow['summary'] ?? ''),
+            'unit' => (string)($aRow['unit'] ?? ''),
+            'object_id' => (int)($aRow['object_id'] ?? 0),
+            'url' => (string)($aRow['object_url'] ?? ''),
+            'title' => (string)($aRow['object_title'] ?? ''),
+            'thread_id' => (string)($aRow['thread_id'] ?? ''),
+            'added' => $iAdded,
+            'added_formatted' => $iAdded ? date('d.m.Y H:i', $iAdded) : '',
+        ];
+    }
+
+    /**
+     * "Posted a comment on Post #45", "Updated bx_events #3", ... Language keys
+     * `_sys_agents_activity_<action>` take {unit}, {object}; the English text below is
+     * the fallback while the keys are not compiled.
+     */
+    public static function describe(array $aRow): string
+    {
+        $sAction = (string)($aRow['action'] ?? '');
+        $sUnit = (string)($aRow['unit'] ?? '');
+        $iObject = (int)($aRow['object_id'] ?? 0);
+        $bOk = (int)($aRow['ok'] ?? 1) === 1;
+
+        $aFallback = [
+            'comment_add' => 'Posted a comment on {target}',
+            'comment_update' => 'Edited a comment on {target}',
+            'comment_delete' => 'Deleted a comment on {target}',
+            'content_add' => 'Created {target}',
+            'content_update' => 'Updated {target}',
+            'content_delete' => 'Deleted {target}',
+            'db_write' => 'Changed {target}',
+            'db_undo' => 'Rolled back changes',
+            'email' => 'Sent an email to {unit}',
+            'lang' => 'Changed language key {unit}',
+            'agent' => 'Agent {unit}: #{object}',
+            'mockup' => 'Updated page block #{object}',
+        ];
+        $sKey = '_sys_agents_activity_' . $sAction;
+        $sTpl = _t($sKey);
+        if ($sTpl === $sKey)
+            $sTpl = $aFallback[$sAction] ?? ucfirst(str_replace('_', ' ', $sAction));
+
+        $sUnitName = self::unitTitle($sUnit);
+        $sObjectTitle = trim((string)($aRow['object_title'] ?? ''));
+        // «Title» when it is known, otherwise "Unit #id"
+        $sTarget = $sObjectTitle !== ''
+            ? ($sUnitName !== '' ? $sUnitName . ': ' : '') . '«' . $sObjectTitle . '»'
+            : trim($sUnitName . ($iObject ? ' #' . $iObject : ''));
+        if ($sAction === 'db_write' && $sObjectTitle === '')
+            $sTarget = 'table ' . $sUnit;
+        // a language string compiled from the first version knows only {unit} #{object}: still show the title
+        if ($sObjectTitle !== '' && strpos($sTpl, '{target}') === false && strpos($sTpl, '{title}') === false)
+            $sTpl .= ': «{title}»';
+        $sText = str_replace(['{target}', '{unit}', '{object}', '{title}'], [$sTarget, $sUnitName, (string)$iObject, $sObjectTitle], $sTpl);
+        $sText = trim(str_replace(' #0', '', $sText));
+        if (!$bOk) {
+            $sFailed = _t('_sys_agents_activity_failed');
+            if ($sFailed === '_sys_agents_activity_failed')
+                $sFailed = 'failed';
+            $sText .= ' (' . $sFailed . ')';
+        }
+        return $sText;
+    }
+
+    protected static function unitTitle(string $sUnit): string
+    {
+        if ($sUnit === '')
+            return '';
+        if (preg_match('/^bx_[a-z0-9_]+$/', $sUnit)) {
+            $s = _t('_' . $sUnit);
+            if ($s !== '_' . $sUnit)
+                return $s;
+            $aModule = BxDolModuleQuery::getInstance()->getModuleByName($sUnit);
+            if ($aModule && !empty($aModule['title']))
+                return $aModule['title'];
+        }
+        return $sUnit;
+    }
+
+    /**
+     * Fill url / title for rows recorded before they were stored. Cheap when nothing is missing.
+     */
+    protected static function resolveTarget(array $aRow): array
+    {
+        $sAction = (string)($aRow['action'] ?? '');
+        $sUnit = (string)($aRow['unit'] ?? '');
+        $iObject = (int)($aRow['object_id'] ?? 0);
+        $bContent = in_array($sAction, ['comment_add', 'comment_update', 'comment_delete', 'content_add', 'content_update'], true);
+        if (!$bContent || $sUnit === '' || $iObject <= 0)
+            return $aRow;
+        $bComment = strpos($sAction, 'comment_') === 0;
+        if (empty($aRow['object_title'])) {
+            $sTitle = $bComment ? self::commentTarget($sUnit, $iObject, 0)['title'] : self::contentTitle($sUnit, $iObject);
+            $aRow['object_title'] = mb_substr(trim(strip_tags($sTitle)), 0, 255);
+        }
+        if (empty($aRow['object_url']))
+            $aRow['object_url'] = $bComment ? self::commentTarget($sUnit, $iObject, 0)['url'] : self::contentUrl($sUnit, $iObject);
+        return $aRow;
+    }
+
+    /**
+     * A row in a content module's entries table (bx_posts_data, bx_forum_discussions...)
+     * is that module's content → [title, url], else null.
+     */
+    protected static function contentByTable(string $sTable, int $iId): ?array
+    {
+        if ($sTable === '' || $iId <= 0 || !preg_match('/^(bx_[a-z0-9]+)_/', $sTable, $m))
+            return null;
+        $sModule = $m[1];
+        try {
+            $oModule = BxDolModule::getInstance($sModule);
+            if (!$oModule || empty($oModule->_oConfig->CNF['TABLE_ENTRIES']) || $oModule->_oConfig->CNF['TABLE_ENTRIES'] !== $sTable)
+                return null;
+        } catch (Throwable $o) {
+            return null;
+        }
+        $sUrl = self::contentUrl($sModule, $iId);
+        if ($sUrl === '')
+            return null;
+        return ['title' => self::contentTitle($sModule, $iId), 'url' => $sUrl];
+    }
+
+    /** Page block → [title, url of its page] for mockup rows. */
+    protected static function pageBlock(int $iBlockId): array
+    {
+        if ($iBlockId <= 0)
+            return [];
+        try {
+            $oDb = BxDolDb::getInstance();
+            $aBlock = $oDb->getRow("SELECT b.`title`, b.`object`, p.`uri` FROM `sys_pages_blocks` b LEFT JOIN `sys_pages` p ON p.`object` = b.`object` WHERE b.`id` = :id LIMIT 1", ['id' => $iBlockId]);
+            if (!$aBlock)
+                return [];
+            $sTitle = (string)$aBlock['title'];
+            if ($sTitle !== '' && $sTitle[0] === '_')
+                $sTitle = _t($sTitle);
+            $sUrl = !empty($aBlock['uri']) ? bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $aBlock['uri'])) : '';
+            return ['title' => $sTitle, 'url' => $sUrl];
+        } catch (Throwable $o) {
+            return [];
+        }
+    }
+
+    protected static function contentUrl(string $sModule, int $iId): string
+    {
+        if ($sModule === '' || $iId <= 0)
+            return '';
+        try {
+            if (!BxDolRequest::serviceExists($sModule, 'get_link'))
+                return '';
+            $s = BxDolService::call($sModule, 'get_link', [$iId]);
+            return is_string($s) ? $s : '';
+        } catch (Throwable $o) {
+            return '';
+        }
+    }
+
+    protected static function contentTitle(string $sModule, int $iId): string
+    {
+        if ($sModule === '' || $iId <= 0)
+            return '';
+        try {
+            if (!BxDolRequest::serviceExists($sModule, 'get_title'))
+                return '';
+            $s = BxDolService::call($sModule, 'get_title', [$iId]);
+            return is_string($s) ? $s : '';
+        } catch (Throwable $o) {
+            return '';
+        }
+    }
+
+    /**
+     * Title and link of the content a comment belongs to: the comments object knows
+     * both (trigger table, base_url) whatever the module, and the comment itself when
+     * the system supports comment permalinks. Falls back to the module's get_title / get_link.
+     */
+    protected static function commentTarget(string $sSystem, int $iContentId, int $iCmtId): array
+    {
+        $aOut = ['title' => '', 'url' => ''];
+        if ($sSystem === '' || $iContentId <= 0)
+            return $aOut;
+
+        $sModule = $sSystem;
+        try {
+            $o = BxDolCmts::getObjectInstance($sSystem, $iContentId);
+            if ($o) {
+                $sModule = (string)($o->getModule() ?: $sSystem);
+                $aOut['title'] = (string)$o->getObjectTitle($iContentId);
+                if ($iCmtId > 0)
+                    $aOut['url'] = (string)$o->getViewUrl($iCmtId);
+                if ($aOut['url'] === '') {
+                    $sBase = (string)$o->getBaseUrl();
+                    if ($sBase !== '' && strpos($sBase, '{') === false)
+                        $aOut['url'] = $sBase;
+                }
+            }
+        } catch (Throwable $e) {
+        }
+
+        if ($aOut['title'] === '')
+            $aOut['title'] = self::contentTitle($sModule, $iContentId);
+        if ($aOut['url'] === '')
+            $aOut['url'] = self::contentUrl($sModule, $iContentId);
+        return $aOut;
+    }
+
+    protected static function clipArray(array $a, int $iMax = 2000): array
+    {
+        $aOut = [];
+        foreach ($a as $k => $v) {
+            if (is_string($v) && mb_strlen($v) > $iMax)
+                $v = mb_substr($v, 0, $iMax) . '…';
+            elseif (is_array($v))
+                $v = self::clipArray($v, $iMax);
+            $aOut[$k] = $v;
+        }
+        return $aOut;
+    }
+}
+
+/** @} */

+ 40 - 0
inc/classes/BxDolAiActivityObserver.php

@@ -0,0 +1,40 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * NeuronAI observer that turns every write tool call of an agent into a
+ * BxDolAiActivity row. Attached in BxDolAiAgent alongside the debug LogObserver.
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+use NeuronAI\Observability\ObserverInterface;
+
+class BxDolAiActivityObserver implements ObserverInterface
+{
+    public function __construct(protected array $aAgent, protected array $aParams = [])
+    {
+    }
+
+    public function onEvent(string $event, object $source, mixed $data = null, ?string $branchId = null): void
+    {
+        if ($event !== 'tool-called' || !is_object($data))
+            return;
+
+        $oTool = $data->tool ?? null;
+        if (!is_object($oTool) || !method_exists($oTool, 'getName'))
+            return;
+
+        try {
+            $aInputs = method_exists($oTool, 'getInputs') ? (array)$oTool->getInputs() : [];
+            $mixedResult = method_exists($oTool, 'getResult') ? $oTool->getResult() : null;
+            BxDolAiActivity::record($this->aAgent, $this->aParams, (string)$oTool->getName(), $aInputs, $mixedResult);
+        } catch (Throwable $o) {
+            bx_log('sys_agents', 'activity observer: ' . $o->getMessage(), BX_LOG_WARN);
+        }
+    }
+}
+
+/** @} */

+ 5 - 2
inc/classes/BxDolAiAgent.php

@@ -29,6 +29,9 @@ class BxDolAiAgent extends RAG
             $logger = new BxDolLoggerDb('sys_agents_' . $this->aAgent['id']);
             $this->observe(new LogObserver($logger));
         }
+
+        // what the agent did (comments, content, SQL) — shown as activity history
+        $this->observe(new BxDolAiActivityObserver($this->aAgent, $this->aParams));
     }
 
     protected function provider(): NeuronAI\Providers\AIProviderInterface
@@ -102,7 +105,7 @@ class BxDolAiAgent extends RAG
     {
         if ($this->aAgent['chat_history_context']) {
             return new BxDolAiChatHistory(
-                thread_id: $this->getСhatHistoryThreadId(),
+                thread_id: $this->getChatHistoryThreadId(),
                 pdo: BxDolDb::getInstance()->getLink(),
                 table: 'sys_agents_chat_history',
                 contextWindow: $this->aAgent['chat_history_context']
@@ -127,7 +130,7 @@ class BxDolAiAgent extends RAG
         };
     }
 
-    protected function getСhatHistoryThreadId(): string
+    protected function getChatHistoryThreadId(): string
     {
         return BxDolAiChat::threadId($this->aAgent, $this->aParams);
     }

+ 87 - 0
inc/classes/BxDolAiChatUi.php

@@ -76,6 +76,16 @@ class BxDolAiChatUi
                         $aParts[] = $aPart;
                 }
             }
+            // trigger payloads (message / alert / form-input / webhook) are stored as raw JSON
+            // in the user turn — show the human text instead of the escaped blob
+            if ($sRole === 'user') {
+                foreach ($aParts as &$aPart) {
+                    if (($aPart['type'] ?? '') === 'text')
+                        $aPart['content'] = $this->humanizeTriggerPayload((string)$aPart['content']);
+                }
+                unset($aPart);
+            }
+
             $sId = '';
             if (!empty($aMessage['__id']) && is_string($aMessage['__id']))
                 $sId = $aMessage['__id'];
@@ -349,6 +359,83 @@ class BxDolAiChatUi
         return trim(preg_replace('/\s+/u', ' ', $s));
     }
 
+    /**
+     * A user turn produced by a trigger is the JSON that callAgent() built
+     * (BxDolAlerts, BxDolAiAlertResponse, webhook, form-input). Return the text
+     * a person would want to read; anything that is not such a payload is returned as is.
+     */
+    public function humanizeTriggerPayload($sText)
+    {
+        $s = trim((string)$sText);
+        if ($s === '' || $s[0] !== '{')
+            return $sText;
+
+        $a = json_decode($s, true);
+        if (!is_array($a))
+            return $sText;
+        $sTrigger = is_string($a['trigger'] ?? null) ? $a['trigger'] : '';
+        if ($sTrigger === '' && (isset($a['form_values']) || isset($a['user_prompt'])))
+            $sTrigger = 'form-input';
+        if ($sTrigger === '')
+            return $sText;
+
+        $fText = function ($m) {
+            $s = trim((string)$m);
+            return $s !== '' ? trim(strip_tags(html_entity_decode($s, ENT_QUOTES, 'UTF-8'))) : '';
+        };
+
+        switch ($sTrigger) {
+            case 'message':
+                $sMsg = $fText($a['message_text'] ?? ($a['message_info']['message'] ?? ''));
+                return $sMsg !== '' ? $sMsg : $sText;
+
+            case 'alert':
+                $sUnit = (string)($a['unit'] ?? '');
+                $sAction = (string)($a['action'] ?? '');
+                $sHead = trim($sUnit . ':' . $sAction, ':');
+                if (!empty($a['object_id']))
+                    $sHead .= ' #' . (int)$a['object_id'];
+                $sBody = '';
+                $aExtra = is_array($a['extra'] ?? null) ? $a['extra'] : [];
+                foreach (['comment_text', 'cmt_text', 'text', 'message', 'title', 'content'] as $sKey) {
+                    if (!empty($aExtra[$sKey]) && is_string($aExtra[$sKey])) {
+                        $sBody = $fText($aExtra[$sKey]);
+                        break;
+                    }
+                }
+                if ($sBody === '' && !empty($aExtra['comment_id']))
+                    $sBody = 'comment #' . (int)$aExtra['comment_id'];
+                return $sHead . ($sBody !== '' ? "\n" . $sBody : '');
+
+            case 'form-input':
+                $aOut = [];
+                $sPrompt = $fText($a['user_prompt'] ?? '');
+                if ($sPrompt !== '')
+                    $aOut[] = $sPrompt;
+                $aValues = is_array($a['form_values'] ?? null) ? $a['form_values'] : [];
+                foreach ($aValues as $sKey => $mixedVal) {
+                    if (is_scalar($mixedVal) && (string)$mixedVal !== '')
+                        $aOut[] = $sKey . ': ' . $fText($mixedVal);
+                }
+                $sHead = trim((string)($a['form_field_name'] ?? ''));
+                return trim($sHead . ($aOut ? "\n" . implode("\n", $aOut) : ''));
+
+            case 'webhook':
+                $aOut = [];
+                foreach ($a as $sKey => $mixedVal) {
+                    if ($sKey === 'trigger')
+                        continue;
+                    if (is_scalar($mixedVal) && (string)$mixedVal !== '')
+                        $aOut[] = $sKey . ': ' . $fText($mixedVal);
+                    elseif (is_array($mixedVal))
+                        $aOut[] = $sKey . ': ' . json_encode($mixedVal, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+                }
+                return 'webhook' . ($aOut ? "\n" . implode("\n", $aOut) : '');
+        }
+
+        return $sText;
+    }
+
     /**
      * Neuron / UI content block from stored chat JSON → UI part.
      *

+ 28 - 5
install/sql/system.sql

@@ -5753,10 +5753,11 @@ INSERT INTO `sys_grid_actions` (`object`, `type`, `name`, `title`, `icon`, `icon
 ('sys_studio_agents_agents', 'bulk', 'delete', '_Delete', '', 0, 1, 1),
 ('sys_studio_agents_agents', 'single', 'manual', '_Run', 'play', 1, 0, 1),
 ('sys_studio_agents_agents', 'single', 'message', '_sys_agents_agents_act_message', 'comment', 1, 0, 2),
-('sys_studio_agents_agents', 'single', 'logs', '_Logs', 'file-alt', 1, 0, 3),
-('sys_studio_agents_agents', 'single', 'edit', '_Edit', 'pencil-alt', 1, 0, 4),
-('sys_studio_agents_agents', 'single', 'wipe_chat_history', '_sys_agents_agents_act_wipe_chat_history', 'eraser', 1, 1, 5),
-('sys_studio_agents_agents', 'single', 'delete', '_Delete', 'remove', 1, 1, 6),
+('sys_studio_agents_agents', 'single', 'activity', '_sys_agents_agents_act_activity', 'history', 1, 0, 3),
+('sys_studio_agents_agents', 'single', 'logs', '_Logs', 'file-alt', 1, 0, 4),
+('sys_studio_agents_agents', 'single', 'edit', '_Edit', 'pencil-alt', 1, 0, 5),
+('sys_studio_agents_agents', 'single', 'wipe_chat_history', '_sys_agents_agents_act_wipe_chat_history', 'eraser', 1, 1, 6),
+('sys_studio_agents_agents', 'single', 'delete', '_Delete', 'remove', 1, 1, 7),
 ('sys_studio_agents_agents', 'independent', 'add', '_adm_form_btn_field_add', '', 0, 0, 1);
 
 -- GRID: Agents Logs
@@ -7061,8 +7062,9 @@ INSERT INTO `sys_agents_tools` (`type`, `title`, `docs`, `params`, `params_user`
 ('mysql_write_safe', 'MySQL Write (safe)', 'INSERT or UPDATE one row by primary key. DELETE/DROP/TRUNCATE/ALTER are blocked. Each change is snapshotted into sys_agents_sql_log (before/after JSON). Roll back with mysql_undo. Use together with MySQL Schema and MySQL Select. Do not enable the stock MySQL Write tool on the same agent.', '{}', NULL, 0, 0, 1, 'BxDolAIToolMysqlWrite', ''),
 ('mysql_undo', 'MySQL Undo', 'Roll back the last mysql_write snapshot (scope=last) or all snapshots in this chat (scope=session), newest first. UPDATE restores before_json. INSERT deletes that primary key internally. Already-undone rows are skipped. Attach to the same agent as MySQL Write (safe).', '{}', NULL, 0, 0, 1, 'BxDolAIToolMysqlUndo', ''),
 
+('prompt_feedback', 'Prompt feedback', 'Call when the user says the previous reply was wrong, corrects you, or you notice you broke a rule. Writes one paste-ready markdown case into sys_agents_prompt_feedback.markdown: question, wrong answer, correction, why, expected behavior, suggested prompt rule, excerpt, and the prompt that was active. Do not mention the log to the visitor.', '{}', NULL, 0, 0, 1, 'BxDolAIToolPromptFeedback', ''),
 ('mockup_upsert', 'Mockup upsert', 'Save a NEO mockup JSON tree (view/row/text/image/icon/button) onto a get_mockup_block service page block. The tree is stored in sys_pages_blocks_data and rendered by the NEO app as a mockup block. Add a "get_mockup_block" service block (system / get_mockup_block / TemplServices) to the page first. Do not use MySQL Write for mockups.', '{}', NULL, 0, 0, 1, 'BxDolAIToolMockup', ''),
-('prompt_feedback', 'Prompt feedback', 'Call when the user says the previous reply was wrong, corrects you, or you notice you broke a rule. Writes one paste-ready markdown case into sys_agents_prompt_feedback.markdown: question, wrong answer, correction, why, expected behavior, suggested prompt rule, excerpt, and the prompt that was active. Do not mention the log to the visitor.', '{}', NULL, 0, 0, 1, 'BxDolAIToolPromptFeedback', '');
+('agent_create', 'Agent create', 'Create or update a Studio agent (sys_agents_agents). Do not use mysql_write on that table. action=catalog|create|update. Never ask for numeric profile_id — catalog.profile_buttons as chat_buttons, then profile=Name or profile_new=Name; create without a profile is refused. preset=comment_reply for comment-reply alert agents.', '{}', NULL, 0, 0, 1, 'BxDolAIToolAgentCreate', '');
 
 CREATE TABLE IF NOT EXISTS `sys_agents_chat_history` (
   `id` bigint unsigned NOT NULL AUTO_INCREMENT,
@@ -7126,6 +7128,27 @@ CREATE TABLE IF NOT EXISTS `sys_agents_sql_log` (
   KEY `table_pk` (`table_name`(64), `pk_name`(64), `pk_value`(63))
 );
 
+CREATE TABLE IF NOT EXISTS `sys_agents_activity` (
+  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+  `agent_id` int(11) unsigned NOT NULL DEFAULT 0,
+  `profile_id` int(11) unsigned NOT NULL DEFAULT 0,
+  `trigger` varchar(32) NOT NULL DEFAULT '',
+  `thread_id` varchar(255) NOT NULL DEFAULT '',
+  `tool` varchar(64) NOT NULL DEFAULT '',
+  `action` varchar(32) NOT NULL DEFAULT '',
+  `unit` varchar(128) NOT NULL DEFAULT '',
+  `object_id` int(11) unsigned NOT NULL DEFAULT 0,
+  `object_url` varchar(1024) NOT NULL DEFAULT '',
+  `object_title` varchar(255) NOT NULL DEFAULT '',
+  `summary` text,
+  `details` mediumtext,
+  `ok` tinyint(1) NOT NULL DEFAULT 1,
+  `added` int(11) unsigned NOT NULL DEFAULT 0,
+  PRIMARY KEY (`id`),
+  KEY `agent_added` (`agent_id`, `added`),
+  KEY `thread_id` (`thread_id`(191))
+);
+
 CREATE TABLE IF NOT EXISTS `sys_agents_prompt_feedback` (
   `id` int(11) NOT NULL AUTO_INCREMENT,
   `agent_id` int(11) NOT NULL DEFAULT 0,

+ 22 - 0
modules/boonex/english/data/langs/system/en.xml

@@ -2426,6 +2426,11 @@
     <string name="_sys_agents_field_model_id_info"><![CDATA[AI model to use for this agent.]]></string>
     <string name="_sys_agents_field_profile_id"><![CDATA[Profile]]></string>
     <string name="_sys_agents_field_profile_id_inf"><![CDATA[AI agent will perform actions under this profile]]></string>
+    <string name="_sys_agents_profile_btn_new"><![CDATA[New: {0}]]></string>
+    <string name="_sys_agents_profile_name_commenter"><![CDATA[Commenter]]></string>
+    <string name="_sys_agents_profile_name_bot"><![CDATA[Bot]]></string>
+    <string name="_sys_agents_profile_required_err"><![CDATA[profile is required. Show chat_buttons with profile_buttons, then pass profile=<tapped name> or profile_new=<name> (for «{0}»).]]></string>
+    <string name="_sys_agents_profile_new_err"><![CDATA[profile_new must be a short display name (1–3 words, like a person): «{0}», «{1}». Not a prompt or sentence. catalog.suggested_new_name is the default.]]></string>
     <string name="_sys_agents_field_acl_levels"><![CDATA[Who can interact]]></string>
     <string name="_sys_agents_field_acl_levels_info"><![CDATA[Membership levels that may chat with this agent, use it in messenger, or fill form fields with it. Leave empty for Nobody. Studio operators can always use the agent to debug. Unauthenticated allows guests.]]></string>
     <string name="_sys_agents_field_trigger"><![CDATA[Trigger]]></string>
@@ -2542,6 +2547,23 @@
     
     <string name="_sys_agents_agents_act_wipe_chat_history"><![CDATA[Wipe chat history]]></string>
     <string name="_sys_agents_agents_act_message"><![CDATA[Message]]></string>
+    <string name="_sys_agents_agents_act_activity"><![CDATA[Activity]]></string>
+    <string name="_sys_agents_agents_popup_activity"><![CDATA[Activity of "{0}"]]></string>
+    <string name="_sys_agents_agents_txt_activity_empty"><![CDATA[Nothing yet]]></string>
+    <string name="_sys_agents_activity_failed"><![CDATA[failed]]></string>
+    <string name="_sys_agents_activity_comment_add"><![CDATA[Posted a comment on {target}]]></string>
+    <string name="_sys_agents_activity_comment_update"><![CDATA[Edited a comment on {target}]]></string>
+    <string name="_sys_agents_activity_comment_delete"><![CDATA[Deleted a comment on {target}]]></string>
+    <string name="_sys_agents_activity_content_add"><![CDATA[Created {target}]]></string>
+    <string name="_sys_agents_activity_content_update"><![CDATA[Updated {target}]]></string>
+    <string name="_sys_agents_activity_content_delete"><![CDATA[Deleted {target}]]></string>
+    <string name="_sys_agents_activity_db_write"><![CDATA[Changed {target}]]></string>
+    <string name="_sys_agents_activity_db_undo"><![CDATA[Rolled back changes]]></string>
+    <string name="_sys_agents_activity_email"><![CDATA[Sent an email to {unit}]]></string>
+    <string name="_sys_agents_activity_lang"><![CDATA[Changed language key {unit}]]></string>
+    <string name="_sys_agents_activity_agent"><![CDATA[Agent {unit}: #{object}]]></string>
+    <string name="_sys_agents_activity_mockup"><![CDATA[Updated page block #{object}]]></string>
+    <string name="_sys_page_block_title_ai_agents_admin"><![CDATA[Agents]]></string>
     <string name="_sys_agents_assistants_chats_msg_loading"><![CDATA[I'm thinking. Please wait.]]></string>
     <string name="_sys_agents_assistants_field_kwg_type"><![CDATA[Type]]></string>
     <string name="_sys_agents_assistants_field_kwg_type_text"><![CDATA[Text]]></string>

+ 22 - 0
modules/boonex/russian/data/langs/system/ru.xml

@@ -2395,6 +2395,11 @@
         <string name="_sys_agents_field_model_id_info"><![CDATA[Модель для использования этим агентом.]]></string>
         <string name="_sys_agents_field_profile_id"><![CDATA[Профиль]]></string>
         <string name="_sys_agents_field_profile_id_inf"><![CDATA[Профайл, от имени которого ИИ агент выполняет действия.]]></string>
+        <string name="_sys_agents_profile_btn_new"><![CDATA[Новый: {0}]]></string>
+        <string name="_sys_agents_profile_name_commenter"><![CDATA[Комментатор]]></string>
+        <string name="_sys_agents_profile_name_bot"><![CDATA[Бот]]></string>
+        <string name="_sys_agents_profile_required_err"><![CDATA[Нужен профиль. Покажите chat_buttons с метками profile_buttons, затем передайте profile=<нажатое имя> или profile_new=<имя> (для «{0}»).]]></string>
+        <string name="_sys_agents_profile_new_err"><![CDATA[profile_new должен быть коротким отображаемым именем (1–3 слова, как имя человека): «{0}», «{1}». Не промпт и не предложение. По умолчанию — catalog.suggested_new_name.]]></string>
         <string name="_sys_agents_field_acl_levels"><![CDATA[Кто может взаимодействовать]]></string>
         <string name="_sys_agents_field_acl_levels_info"><![CDATA[Уровни членства, которые могут общаться с этим агентом, писать ему в мессенджере или заполнять поля форм с его помощью. Оставьте пустым — никто. Операторы Studio всегда могут использовать агента для отладки. «Неаутентифицированный» разрешает гостям.]]></string>
         <string name="_sys_agents_field_trigger"><![CDATA[Триггер]]></string>
@@ -2512,6 +2517,23 @@
 
         <string name="_sys_agents_agents_act_wipe_chat_history"><![CDATA[Очистить историю чата]]></string>
         <string name="_sys_agents_agents_act_message"><![CDATA[Сообщение]]></string>
+        <string name="_sys_agents_agents_act_activity"><![CDATA[Действия]]></string>
+        <string name="_sys_agents_agents_popup_activity"><![CDATA[Действия агента «{0}»]]></string>
+        <string name="_sys_agents_agents_txt_activity_empty"><![CDATA[Пока ничего]]></string>
+        <string name="_sys_agents_activity_failed"><![CDATA[ошибка]]></string>
+        <string name="_sys_agents_activity_comment_add"><![CDATA[Оставил комментарий: {target}]]></string>
+        <string name="_sys_agents_activity_comment_update"><![CDATA[Изменил комментарий: {target}]]></string>
+        <string name="_sys_agents_activity_comment_delete"><![CDATA[Удалил комментарий: {target}]]></string>
+        <string name="_sys_agents_activity_content_add"><![CDATA[Создал {target}]]></string>
+        <string name="_sys_agents_activity_content_update"><![CDATA[Изменил {target}]]></string>
+        <string name="_sys_agents_activity_content_delete"><![CDATA[Удалил {target}]]></string>
+        <string name="_sys_agents_activity_db_write"><![CDATA[Изменил {target}]]></string>
+        <string name="_sys_agents_activity_db_undo"><![CDATA[Откатил изменения]]></string>
+        <string name="_sys_agents_activity_email"><![CDATA[Отправил письмо на {unit}]]></string>
+        <string name="_sys_agents_activity_lang"><![CDATA[Изменил языковой ключ {unit}]]></string>
+        <string name="_sys_agents_activity_agent"><![CDATA[Агент ({unit}): #{object}]]></string>
+        <string name="_sys_agents_activity_mockup"><![CDATA[Изменил блок страницы #{object}]]></string>
+        <string name="_sys_page_block_title_ai_agents_admin"><![CDATA[Агенты]]></string>
         <string name="_sys_agents_assistants_chats_msg_loading"><![CDATA[Я думаю. Пожалуйста, подождите.]]></string>
         <string name="_sys_agents_assistants_field_kwg_type"><![CDATA[Тип]]></string>
         <string name="_sys_agents_assistants_field_kwg_type_text"><![CDATA[Текст]]></string>

+ 1 - 0
scripts/railway/.railwayignore

@@ -0,0 +1 @@
+plugins/composer/tmp-*.file~

+ 66 - 2
studio/template/scripts/BxBaseStudioAgentsAgents.php

@@ -128,7 +128,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
 
             $iProfileId = $oForm->getCleanValue('profile_id');
             if(empty($iProfileId)) {
-                $iProfileId = (int)getParam('sys_agents_profile');
+                $iProfileId = (int)getParam('sys_profile_bot');
                 if(empty($iProfileId))
                     $iProfileId = current(bx_srv('system', 'get_options_agents_profile', [false], 'TemplServices'))['key'];
 
@@ -197,7 +197,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
 
             $iProfileId = $oForm->getCleanValue('profile_id');
             if(empty($iProfileId)) {
-                $iProfileId = (int)getParam('sys_agents_profile');
+                $iProfileId = (int)getParam('sys_profile_bot');
                 if(empty($iProfileId))
                     $iProfileId = current(bx_srv('system', 'get_options_agents_profile', [false], 'TemplServices'))['key'];
 
@@ -444,6 +444,8 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
     {
         $mixedResult = parent::_delete($mixedId);
         $this->_oDb->cleanCache('sys_agents_with_alert');
+        if ($mixedResult)
+            BxDolAiActivity::deleteForAgent((int)$mixedId);
 
         return $mixedResult;
     }
@@ -1221,6 +1223,68 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
         return parent::_getActionDefault ($sType, $sKey, $a, $isSmall, $isDisabled, $aRow);
     }
 
+    /**
+     * Chat history only makes sense for agents people talk to; event agents get "activity" instead.
+     */
+    protected function _getActionMessage ($sType, $sKey, $a, $isSmall = false, $isDisabled = false, $aRow = array())
+    {
+        if (!in_array($aRow['trigger'], ['manual', 'message'], true))
+            return '';
+        return parent::_getActionDefault ($sType, $sKey, $a, $isSmall, $isDisabled, $aRow);
+    }
+
+    protected function _getActionActivity ($sType, $sKey, $a, $isSmall = false, $isDisabled = false, $aRow = array())
+    {
+        if (in_array($aRow['trigger'], ['manual', 'message'], true))
+            return '';
+        return parent::_getActionDefault ($sType, $sKey, $a, $isSmall, $isDisabled, $aRow);
+    }
+
+    /**
+     * What an event-driven agent did: BxDolAiActivity rows, newest first.
+     */
+    public function performActionActivity()
+    {
+        $iId = $this->_getId();
+        $aAgent = BxDolAiQuery::getAgentObject($iId);
+        if (!$aAgent) {
+            echoJson(['msg' => _t('_sys_txt_error_occured')]);
+            return;
+        }
+
+        $aItems = BxDolAiActivity::listForAgent($iId, 0, 200);
+
+        $sTitle = _t('_sys_agents_agents_popup_activity', !empty($aAgent['title']) ? $aAgent['title'] : $aAgent['name']);
+        if (strpos($sTitle, '_sys_agents_agents_popup_activity') === 0)
+            $sTitle = 'Activity of "' . (!empty($aAgent['title']) ? $aAgent['title'] : $aAgent['name']) . '"';
+        $sEmpty = _t('_sys_agents_agents_txt_activity_empty');
+        if ($sEmpty === '_sys_agents_agents_txt_activity_empty')
+            $sEmpty = 'Nothing yet';
+
+        $sRows = '';
+        foreach ($aItems as $aItem) {
+            $sText = bx_process_output($aItem['text']);
+            if ($aItem['url'] !== '')
+                $sText = '<a href="' . bx_html_attribute($aItem['url']) . '" target="_blank">' . $sText . '</a>';
+            $sSummary = $aItem['summary'] !== '' ? '<div class="bx-def-font-small" style="opacity:.75;">' . bx_process_output($aItem['summary']) . '</div>' : '';
+            $sRows .= '<div class="bx-def-padding-sec-tb" style="border-bottom:1px solid rgba(127,127,127,.2);' . ($aItem['ok'] ? '' : 'color:#c0392b;') . '">'
+                . '<div style="display:flex;gap:1rem;justify-content:space-between;">'
+                    . '<div>' . $sText . '</div>'
+                    . '<div class="bx-def-font-small" style="white-space:nowrap;opacity:.6;">' . bx_process_output($aItem['added_formatted']) . '</div>'
+                . '</div>'
+                . $sSummary
+            . '</div>';
+        }
+        if ($sRows === '')
+            $sRows = '<div class="bx-def-padding" style="opacity:.6;">' . bx_process_output($sEmpty) . '</div>';
+
+        $sContent = BxTemplStudioFunctions::getInstance()->popupBox('popup_agent_activity_' . $iId, $sTitle,
+            '<div class="bx-agents-popup-activity" style="max-height:70vh;overflow:auto;min-width:40rem;">' . $sRows . '</div>'
+        );
+
+        echoJson(['popup' => ['html' => $sContent, 'options' => ['closeOnOuterClick' => false]]]);
+    }
+
     protected function _getAgentWithProfile($iProfileId, $iExcludeAgentId = 0)
     {
         $aAgents = BxDolAi::getInstance()->getAgentsByProfileId($iProfileId);

+ 236 - 0
template/scripts/BxBaseServices.php

@@ -138,6 +138,12 @@ class BxBaseServices extends BxDol implements iBxDolProfileService
             'GetBlockAiAgentOperator' => 'BxBaseServices',
             'GetAiChatThreads' => 'BxBaseServices',
             'GetAiChatThread' => 'BxBaseServices',
+            'GetAiAgents' => 'BxBaseServices',
+            'GetBlockAiAgentsAdmin' => 'BxBaseServices',
+            'SetAiAgentActive' => 'BxBaseServices',
+            'GetAiAgentActivity' => 'BxBaseServices',
+            'GetAiAgentChatThreads' => 'BxBaseServices',
+            'GetAiAgentChatThread' => 'BxBaseServices',
             'GetMockupBlock' => 'BxBaseServices',
         );
     }
@@ -1995,6 +2001,236 @@ class BxBaseServices extends BxDol implements iBxDolProfileService
         return BxDolAiTrigger::getInstance('chat')->getThread($iAgentId, $sThreadId);
     }
 
+    /**
+     * a cut-down Studio > Agents for operators:
+     * the list with on/off, chat history for manual/message agents and the
+     * activity history (what the agent did) for event-driven ones.
+     *
+     * API: `api.php?r=system/get_ai_agents/TemplServices`
+     * → `{agents: [{id, name, title, description, trigger, trigger_title, alert, active, async,
+     *    has_chat, model, profile: {id, name, url, thumb}, activity_count, activity_last, added}], studio_url}`
+     */
+    public function serviceGetAiAgents()
+    {
+        if (!isAdmin())
+            return ['error' => _t('_sys_agents_unauthorized'), 'code' => 403];
+
+        $oDb = BxDolDb::getInstance();
+        $aRows = $oDb->getAll("SELECT `a`.*, `m`.`title` AS `model_title` FROM `sys_agents_agents` AS `a` LEFT JOIN `sys_agents_models` AS `m` ON `m`.`id` = `a`.`model_id` ORDER BY `a`.`added` DESC, `a`.`id` DESC");
+        $aStats = BxDolAiActivity::statsByAgent();
+
+        $aAgents = [];
+        foreach ($aRows as $aRow) {
+            $iId = (int)$aRow['id'];
+            $aAgents[] = [
+                'id' => $iId,
+                'name' => (string)$aRow['name'],
+                'title' => (string)(!empty($aRow['title']) ? $aRow['title'] : $aRow['name']),
+                'description' => (string)($aRow['description'] ?? ''),
+                'trigger' => (string)$aRow['trigger'],
+                'trigger_title' => $this->_aiAgentTriggerTitle((string)$aRow['trigger']),
+                'alert' => (string)($aRow['alert'] ?? ''),
+                'active' => (int)$aRow['active'],
+                'async' => (int)($aRow['async'] ?? 0),
+                'has_chat' => in_array($aRow['trigger'], ['manual', 'message'], true) ? 1 : 0,
+                'model' => (string)($aRow['model_title'] ?? ''),
+                'profile' => $this->_aiAgentProfileInfo((int)$aRow['profile_id']),
+                'activity_count' => (int)($aStats[$iId]['count'] ?? 0),
+                'activity_last' => (int)($aStats[$iId]['last'] ?? 0),
+                'added' => (int)($aRow['added'] ?? 0),
+            ];
+        }
+
+        return [
+            'agents' => $aAgents,
+            'studio_url' => bx_absolute_url('studio/agents.php?page=agents'),
+        ];
+    }
+
+    /**
+     * Page block with the same list, for operators only (everyone else gets an
+     * empty block). Put it on the Dashboard or any page in Studio > Pages:
+     * `{"module":"system","method":"get_block_ai_agents_admin","class":"TemplServices"}`.
+     * The App renders it as the `ai_agents_admin` element; there is no HTML version.
+     */
+    public function serviceGetBlockAiAgentsAdmin()
+    {
+        if (!bx_is_api())
+            return '';
+        if (!isAdmin())
+            return [];
+
+        $aList = $this->serviceGetAiAgents();
+        if (!is_array($aList) || isset($aList['error']))
+            return [];
+
+        // the operator chat (Studio > Settings > Agents) rides along as the first tab of
+        // the same block, so the Dashboard needs one block, not two
+        $aList['chat'] = null;
+        $aList['chat_title'] = '';
+        $iOperator = (int)getParam('sys_agents_operator_agent');
+        if ($iOperator > 0) {
+            $aOperatorBlock = $this->serviceGetBlockAiAgentOperator();
+            if (is_array($aOperatorBlock) && !empty($aOperatorBlock[0]['data']) && is_array($aOperatorBlock[0]['data'])) {
+                $aList['chat'] = $aOperatorBlock[0]['data'];
+                $aOperator = BxDolAiQuery::getAgentObject($iOperator);
+                $aList['chat_title'] = (string)(!empty($aOperator['title']) ? $aOperator['title'] : ($aOperator['name'] ?? ''));
+            }
+        }
+
+        return [bx_api_get_block('ai_agents_admin', $aList)];
+    }
+
+    /**
+     * API: `api.php?r=system/set_ai_agent_active/TemplServices&params[]=<agent id>&params[]=<0|1>`
+     */
+    public function serviceSetAiAgentActive($iAgentId, $iActive = 1)
+    {
+        if (!isAdmin())
+            return ['error' => _t('_sys_agents_unauthorized'), 'code' => 403];
+
+        $iAgentId = (int)$iAgentId;
+        $aAgent = $iAgentId ? BxDolAiQuery::getAgentObject($iAgentId) : false;
+        if (!$aAgent)
+            return ['error' => _t('_sys_agents_agent_not_found'), 'code' => 404];
+
+        $iActive = (int)$iActive ? 1 : 0;
+        $oDb = BxDolDb::getInstance();
+        $oDb->query("UPDATE `sys_agents_agents` SET `active` = :active WHERE `id` = :id", ['active' => $iActive, 'id' => $iAgentId]);
+        $oDb->cleanCache('sys_agents_with_alert');
+        $oDb->cleanMemory('sys_agents_with_form_' . (string)($aAgent['form_object'] ?? ''));
+
+        return ['id' => $iAgentId, 'active' => $iActive];
+    }
+
+    /**
+     * API: `api.php?r=system/get_ai_agent_activity/TemplServices&params[]=<agent id>&params[]=<start>&params[]=<limit>`
+     * → `{items: [{id, action, tool, ok, text, summary, unit, object_id, url, added, added_formatted}], has_more}`
+     */
+    public function serviceGetAiAgentActivity($iAgentId, $iStart = 0, $iLimit = 50)
+    {
+        if (!isAdmin())
+            return ['error' => _t('_sys_agents_unauthorized'), 'code' => 403];
+
+        $iAgentId = (int)$iAgentId;
+        if (!$iAgentId || !BxDolAiQuery::getAgentObject($iAgentId))
+            return ['error' => _t('_sys_agents_agent_not_found'), 'code' => 404];
+
+        $iLimit = max(1, min(200, (int)$iLimit));
+        $aItems = BxDolAiActivity::listForAgent($iAgentId, (int)$iStart, $iLimit + 1);
+        $bMore = count($aItems) > $iLimit;
+        if ($bMore)
+            array_pop($aItems);
+
+        return ['items' => $aItems, 'has_more' => $bMore ? 1 : 0];
+    }
+
+    /**
+     * Every conversation of an agent (all people, guests, contexts) — operators
+     * only; the App counterpart of the Studio chat popup. Transcripts are not
+     * included, see serviceGetAiAgentChatThread.
+     *
+     * API: `api.php?r=system/get_ai_agent_chat_threads/TemplServices&params[]=<agent id>`
+     * → `{threads: [{thread_id, title, status, closed_reason, created_at, updated_at, messages_count, preview}]}`
+     */
+    public function serviceGetAiAgentChatThreads($iAgentId)
+    {
+        if (!isAdmin())
+            return ['error' => _t('_sys_agents_unauthorized'), 'code' => 403];
+
+        $iAgentId = (int)$iAgentId;
+        $aAgent = $iAgentId ? BxDolAiQuery::getAgentObject($iAgentId) : false;
+        if (!$aAgent)
+            return ['error' => _t('_sys_agents_agent_not_found'), 'code' => 404];
+
+        $aThreads = BxDolAiChat::getInstance()->listAgentChatThreads($aAgent);
+
+        $aOut = [];
+        foreach ($aThreads as $aThread) {
+            $aMessages = is_array($aThread['messages'] ?? null) ? $aThread['messages'] : [];
+            if (!$aMessages)
+                continue;
+            $sPreview = '';
+            foreach (array_reverse($aMessages) as $aMessage) {
+                foreach ((array)($aMessage['parts'] ?? []) as $aPart) {
+                    if (($aPart['type'] ?? '') === 'text' && trim((string)($aPart['content'] ?? '')) !== '') {
+                        $sPreview = trim(strip_tags((string)$aPart['content']));
+                        break 2;
+                    }
+                }
+            }
+            if (mb_strlen($sPreview) > 160)
+                $sPreview = mb_substr($sPreview, 0, 159) . '…';
+
+            $aOut[] = [
+                'thread_id' => (string)$aThread['thread_id'],
+                'title' => (string)$aThread['title'],
+                'status' => (string)($aThread['status'] ?? 'opened'),
+                'closed_reason' => (string)($aThread['closed_reason'] ?? ''),
+                'created_at' => (string)($aThread['created_at'] ?? ''),
+                'updated_at' => (string)($aThread['updated_at'] ?? ''),
+                'messages_count' => count($aMessages),
+                'preview' => $sPreview,
+            ];
+        }
+
+        return ['threads' => $aOut];
+    }
+
+    /**
+     * API: `api.php?r=system/get_ai_agent_chat_thread/TemplServices&params[]=<agent id>&params[]=<thread id>`
+     * → `{messages, status, closed_reason, artifacts}` — same message shape as get_ai_chat_thread.
+     */
+    public function serviceGetAiAgentChatThread($iAgentId, $sThreadId = '')
+    {
+        if (!isAdmin())
+            return ['error' => _t('_sys_agents_unauthorized'), 'code' => 403];
+
+        $iAgentId = (int)$iAgentId;
+        $aAgent = $iAgentId ? BxDolAiQuery::getAgentObject($iAgentId) : false;
+        if (!$aAgent)
+            return ['error' => _t('_sys_agents_agent_not_found'), 'code' => 404];
+
+        $oChat = BxDolAiChat::getInstance();
+        $sThreadId = trim((string)$sThreadId);
+        if ($sThreadId === '')
+            return ['error' => _t('_sys_agents_agent_not_found'), 'code' => 404];
+
+        $aMessages = $oChat->getChatHistoryUiMessagesByThread($aAgent, $sThreadId);
+        if ($aMessages === false)
+            return ['error' => _t('_sys_agents_agent_not_found'), 'code' => 404];
+
+        $sReason = '';
+        $oDb = BxDolDb::getInstance();
+        if ($oDb->isFieldExists('sys_agents_chat_history', 'closed_reason'))
+            $sReason = trim((string)$oDb->getOne("SELECT `closed_reason` FROM `sys_agents_chat_history` WHERE `thread_id` = :t", ['t' => $sThreadId]));
+
+        return [
+            'messages' => is_array($aMessages) ? $aMessages : [],
+            'status' => $sReason !== '' ? 'closed' : 'opened',
+            'closed_reason' => $sReason,
+            'artifacts' => $oChat->getChatHistoryArtifactsByThread($aAgent, $sThreadId),
+        ];
+    }
+
+    protected function _aiAgentTriggerTitle($sTrigger)
+    {
+        $sKey = '_sys_agents_field_trigger_' . str_replace('-', '_', (string)$sTrigger);
+        $s = _t($sKey);
+        return $s !== $sKey ? $s : (string)$sTrigger;
+    }
+
+    protected function _aiAgentProfileInfo($iProfileId)
+    {
+        $iProfileId = (int)$iProfileId;
+        $oProfile = $iProfileId ? BxDolProfile::getInstance($iProfileId) : false;
+        if (!$oProfile)
+            return ['id' => $iProfileId, 'display_name' => $iProfileId ? '#' . $iProfileId : '', 'url' => false, 'url_avatar' => '', 'module' => ''];
+
+        // same shape as the ai_agent block's agent_profile, so the App's Profile molecule can render it
+        return BxDolProfile::getData($oProfile);
+    }
+
     /**
      * Agent-written NEO mockup block (Studio: system / get_mockup_block / TemplServices).
      * The tree is written by the `mockup_upsert` tool into sys_pages_blocks_data for this block;