Plugin Directory

Changeset 3775519


Ignore:
Timestamp:
10/30/2026 02:56:36 PM (4 days ago)
Author:
arkenon
Message:

Release 1.3.4: dynamic tool loading, system abilities, post terms

Location:
agent-mod
Files:
364 added
15 edited

Legend:

Unmodified
Added
Removed
  • agent-mod/trunk/agent-mod.php

    r3770241 r3775519  
    77 * Requires at least: 7.0
    88 * Requires PHP:      7.4
    9  * Version:           1.3.3
     9 * Version:           1.3.4
    1010 * Author:            Kadim Gültekin
    1111 * Author URI:        https://profiles.wordpress.org/arkenon/
  • agent-mod/trunk/includes/App.php

    r3761762 r3775519  
    1515use AgentMod\Services\AbilityRegistrarService;
    1616use AgentMod\Services\AskUserAbilityService;
     17use AgentMod\Services\FindAbilitiesAbilityService;
    1718use AgentMod\Common\DI;
    1819use AgentMod\Presentation\ControllerInit;
    … …  
    3940                NavigationAbilityService::class,
    4041                AskUserAbilityService::class,
     42                FindAbilitiesAbilityService::class,
    4143                LibraryService::class,
    4244                SettingsService::class,
  • agent-mod/trunk/includes/Common/Constants.php

    r3770241 r3775519  
    5858
    5959        /**
     60         * Category of AgentMod's system abilities: the tools that are part of how
     61         * the agent works (ask-user, present-plan, request-approval,
     62         * find-abilities) rather than site capabilities. AgentMod alone decides
     63         * when they are offered, so they are never part of an ability selection.
     64         *
     65         * @since 1.3.4
     66         */
     67        public const SYSTEM_ABILITY_CATEGORY = 'agent-mod-system';
     68
     69        /**
     70         * Default tool loading strategy: 'dynamic' sends a small tool set and lets
     71         * the agent load the rest through agent-mod/find-abilities; 'all' sends
     72         * every allowed ability on every request.
     73         *
     74         * @since 1.3.4
     75         */
     76        public const AI_TOOL_LOADING = 'dynamic';
     77
     78        /**
     79         * Allowed-ability count up to which dynamic loading still sends every
     80         * ability: a small set costs less than the extra find round trip.
     81         * Filterable via `agent_mod_dynamic_tools_threshold`.
     82         *
     83         * @since 1.3.4
     84         */
     85        public const AI_DYNAMIC_TOOLS_THRESHOLD = 15;
     86
     87        /**
     88         * Most abilities one agent-mod/find-abilities call returns (and loads).
     89         *
     90         * @since 1.3.4
     91         */
     92        public const AI_FIND_ABILITIES_LIMIT = 8;
     93
     94        /**
    6095         * HTTP timeout (seconds) for requests to AI provider APIs.
    6196         *
    … …  
    70105
    71106        /**
    72          * How many of the most recent tool-result messages are re-sent verbatim on
    73          * each iteration of the tool-calling loop; older ones are elided to stubs.
    74          * Filterable via `agent_mod_history_keep_tool_results`.
     107         * How many of the most recent tool-result messages are always re-sent
     108         * verbatim, even once the history has been compacted. Filterable via
     109         * `agent_mod_history_keep_tool_results`.
    75110         *
    76111         * @since 1.1.5
    77112         */
    78         public const AI_HISTORY_KEEP_TOOL_RESULTS = 4;
     113        public const AI_HISTORY_KEEP_TOOL_RESULTS = 6;
     114
     115        /**
     116         * Estimated history size (tokens) at which the tool-calling loop starts
     117         * eliding old tool results. Below it the history is sent whole. Filterable
     118         * via `agent_mod_history_compact_trigger_tokens`.
     119         *
     120         * @since 1.3.4
     121         */
     122        public const AI_HISTORY_COMPACT_TRIGGER_TOKENS = 80000;
     123
     124        /**
     125         * Estimated history size (tokens) a compaction elides down to. The gap to
     126         * the trigger keeps compactions rare, so the sent prefix stays stable (and
     127         * cacheable) between them. Filterable via
     128         * `agent_mod_history_compact_target_tokens`.
     129         *
     130         * @since 1.3.4
     131         */
     132        public const AI_HISTORY_COMPACT_TARGET_TOKENS = 40000;
    79133
    80134        /**
    … …  
    234288                        ),
    235289                        __(
    236                                 'When a tool can answer the user accurately, call it before responding. Base your answers on tool results rather than guessing.',
     290                                'When a tool can answer the user accurately, call it before responding, unless the answer is already given in the site context or earlier in the conversation. Base your answers on tool results rather than guessing.',
    237291                                'agent-mod'
    238292                        ),
  • agent-mod/trunk/includes/Presentation/Admin/Controllers/SettingsController.php

    r3761762 r3775519  
    239239                                'fields'        => [
    240240                                        [
    241                                                 'fieldType'   => 'select',
    242                                                 'name'        => 'ability_source',
    243                                                 'fieldLabel'  => __('Ability Source', 'agent-mod'),
    244                                                 'fieldHelpText'        => __('Choose whether to allow all abilities or only selected ones.', 'agent-mod'),
    245                                                 'options'     => 'All Abilities : all, Selected Abilities : selected',
    246                                                 'default'     => 'all',
    247                                         ],
    248                                         [
    249                                                 'fieldType'    => 'select',
    250                                                 'multiple'     => true,
    251                                                 'name'         => 'allowed_abilities',
    252                                                 'fieldLabel'   => __('Allowed Abilities', 'agent-mod'),
    253                                                 'className'    => 'agent-mod-wp-core-ui-select-multiple',
    254                                                 'fieldHelpText' => __('Select the abilities to allow. Used only when Ability Source is set to Selected Abilities.', 'agent-mod'),
    255                                                 'options'      => $this->getAbilityOptions(),
    256                                                 'default'      => [],
    257                                                 'dependencies' => [
    258                                                         'relation'   => 'and',
    259                                                         'conditions' => [
    260                                                                 [
    261                                                                         'field'    => 'ability_source',
    262                                                                         'operator' => '==',
    263                                                                         'value'    => 'selected',
    264                                                                 ],
     241                                                'fieldType'     => 'select',
     242                                                'name'          => 'tool_loading',
     243                                                'fieldLabel'    => __('Tool Loading', 'agent-mod'),
     244                                                'fieldHelpText' => __('Dynamic (recommended): the agent starts with a few tools and loads the abilities it needs by searching for them, which keeps every request small. All: every ability is sent with every request; costs far more tokens on sites with many abilities.', 'agent-mod'),
     245                                                'options'       => [
     246                                                        [
     247                                                                'label' => __('Dynamic (recommended)', 'agent-mod'),
     248                                                                'value' => 'dynamic',
     249                                                        ],
     250                                                        [
     251                                                                'label' => __('All abilities', 'agent-mod'),
     252                                                                'value' => 'all',
    265253                                                        ],
    266254                                                ],
     255                                                'default'       => Constants::AI_TOOL_LOADING,
    267256                                        ],
    268257                                ],
    … …  
    357346                );
    358347        }
    359 
    360         /**
    361          * Get allowed abilities options for the settings field.
    362          *
    363          * @return array<int, array<string, string>>
    364          * @since 1.0.0
    365          */
    366         private function getAbilityOptions(): array
    367         {
    368                 $options = [];
    369 
    370                 $abilities = wp_get_abilities();
    371                 foreach ($abilities as $ability) {
    372                         $options[] = [
    373                                 'label' => $ability->get_label(),
    374                                 'value' => $ability->get_name(),
    375                         ];
    376                 }
    377 
    378                 return $options;
    379         }
    380348}
  • agent-mod/trunk/includes/Services/AI/AIClientAdapter.php

    r3770241 r3775519  
    8888
    8989        /**
     90         * Run-scoped ability catalog (dynamic tool loading).
     91         *
     92         * @var AbilityCatalog
     93         * @since 1.3.4
     94         */
     95        private AbilityCatalog $catalog;
     96
     97        /**
    9098         * Constructor (PHP-DI autowired).
    9199         *
    … …  
    95103         * @param HistoryCompactor      $historyCompactor In-loop history compactor.
    96104         * @param AskUserAbilityService $askUser          Ask-user ability service.
     105         * @param AbilityCatalog        $catalog          Run-scoped ability catalog.
    97106         *
    98107         * @since 1.0.0
    … …  
    103112                HttpTimeoutManager $httpTimeouts,
    104113                HistoryCompactor $historyCompactor,
    105                 AskUserAbilityService $askUser
     114                AskUserAbilityService $askUser,
     115                AbilityCatalog $catalog
    106116        ) {
    107117                $this->toolCallRepairs  = $toolCallRepairs;
    … …  
    110120                $this->historyCompactor = $historyCompactor;
    111121                $this->askUser          = $askUser;
     122                $this->catalog          = $catalog;
    112123        }
    113124
    … …  
    126137         *                                           confirmation gate entirely.
    127138         * @param bool        $webSearchEnabled  Whether to enable the provider's native web search tool.
     139         * @param WP_Ability[]|null $tools       Tools sent at the start under dynamic tool loading (see
     140         *                                       AbilityResolver::selectTools()); null sends every ability.
    128141         *
    129142         * @return AgentResponse
    … …  
    139152                string $requestId = '',
    140153                array $autoApprovedAbilities = [],
    141                 bool $webSearchEnabled = false
     154                bool $webSearchEnabled = false,
     155                ?array $tools = null
    142156        ): AgentResponse {
    143157                if (! function_exists('wp_ai_client_prompt')) {
    … …  
    150164                $this->toolCallRepairs->register();
    151165                $this->httpTimeouts->register();
     166                $this->catalog->scope($abilities);
    152167
    153168                try {
    … …  
    161176                                $requestId,
    162177                                $autoApprovedAbilities,
    163                                 $webSearchEnabled
     178                                $webSearchEnabled,
     179                                [],
     180                                $tools
    164181                        );
    165182                } finally {
    166183                        $this->toolCallRepairs->unregister();
    167184                        $this->httpTimeouts->unregister();
     185                        $this->catalog->clear();
    168186
    169187                        // Progress and stop flags are only meaningful while the request is
    … …  
    202220         * @param string      $decision              'approve', 'decline' or 'answer'.
    203221         * @param array<string, mixed> $answerPayload Function response for the ask-user call ('answer' only).
     222         * @param WP_Ability[]|null $tools       Tools sent at the start under dynamic tool loading; null sends every ability.
    204223         *
    205224         * @return AgentResponse
    … …  
    218237                array $executedCalls,
    219238                string $decision,
    220                 array $answerPayload = []
     239                array $answerPayload = [],
     240                ?array $tools = null
    221241        ): AgentResponse {
    222242                if (! function_exists('wp_ai_client_prompt')) {
    … …  
    243263                $this->toolCallRepairs->register();
    244264                $this->httpTimeouts->register();
     265                $this->catalog->scope($abilities);
    245266
    246267                try {
    … …  
    288309                                $autoApprovedAbilities,
    289310                                $webSearchEnabled,
    290                                 $executedCalls
     311                                $executedCalls,
     312                                $tools
    291313                        );
    292314                } finally {
    293315                        $this->toolCallRepairs->unregister();
    294316                        $this->httpTimeouts->unregister();
     317                        $this->catalog->clear();
    295318
    296319                        if ('' !== $requestId) {
    … …  
    565588         *                                                           confirmation chain; seeds the executed
    566589         *                                                           list so responses report the whole chain.
     590         * @param WP_Ability[]|null $tools Tools sent at the start under dynamic tool loading; null sends every ability.
    567591         *
    568592         * @return AgentResponse
    … …  
    579603                array $autoApprovedAbilities = [],
    580604                bool $webSearchEnabled = false,
    581                 array $initialToolCalls = []
     605                array $initialToolCalls = [],
     606                ?array $tools = null
    582607        ): AgentResponse {
    583608                $resolver   = new WP_AI_Client_Ability_Function_Resolver(...$abilities);
    … …  
    586611                $tokenUsage = [];
    587612                $callCounts = [];
     613                // History index of the latest result of each read call signature, and
     614                // the index below which the compactor has elided results in this run.
     615                $readIndexes = [];
     616                $watermark   = 0;
    588617
    589618                for ($i = 0; $i < $maxToolCalls; $i++) {
    … …  
    610639                        // The provider gets a compacted copy; $history itself stays intact
    611640                        // for the AgentResponse / confirmation-resume flow.
     641                        $compacted = $this->historyCompactor->compact($history, $watermark);
     642
     643                        // A read whose result was elided may run again: blocking it would
     644                        // leave the model with neither the data nor a way to get it back.
     645                        $this->forgetElidedReads($callCounts, $readIndexes, $watermark);
     646
    612647                        $builder = wp_ai_client_prompt()
    613                                 ->with_history(...$this->historyCompactor->compact($history))
     648                                ->with_history(...$compacted)
    614649                                ->using_system_instruction($systemInstruction);
    615650
    … …  
    622657                        }
    623658
    624                         if (! empty($abilities)) {
    625                                 $builder->using_abilities(...$abilities);
     659                        $sentTools = null === $tools ? $abilities : $this->withLoadedTools($tools, $abilities, $history);
     660
     661                        if (! empty($sentTools)) {
     662                                $builder->using_abilities(...$sentTools);
    626663                        }
    627664
    … …  
    759796
    760797                        $resultMessage = $this->executeAbilities($resolver, $message, $callCounts);
    761                         $history[]     = $resultMessage;
     798
     799                        foreach ($requestedCalls as $call) {
     800                                if (! $this->isWriteAbility((string) $call['name'])) {
     801                                        $readIndexes[$this->readSignature((string) $call['name'], $call['args'])] = count($history);
     802                                }
     803                        }
     804
     805                        $history[] = $resultMessage;
    762806
    763807                        $this->logToolFailures($resultMessage);
    … …  
    781825
    782826                $finalBuilder = wp_ai_client_prompt()
    783                         ->with_history(...$this->historyCompactor->compact($history))
     827                        ->with_history(...$this->historyCompactor->compact($history, $watermark))
    784828                        ->using_system_instruction($systemInstruction);
    785829
    … …  
    817861                        $this->extractStopReason($finalResult)
    818862                );
     863        }
     864
     865        /**
     866         * Appends the abilities loaded during the run to the initial tool set.
     867         *
     868         * Loaded abilities (find-abilities matches and abilities the model called)
     869         * go after the initial set, in the order they were loaded, and stay for the
     870         * rest of the run. The tool block therefore only ever grows at its end,
     871         * which keeps the provider's cached prompt prefix as long as possible.
     872         * Only abilities of the run's allowed set are added.
     873         *
     874         * @param WP_Ability[] $tools     Initial tool set.
     875         * @param WP_Ability[] $abilities Abilities allowed in the run.
     876         * @param Message[]    $history   Wire-format history of the run.
     877         *
     878         * @return WP_Ability[]
     879         * @since 1.3.4
     880         */
     881        private function withLoadedTools(array $tools, array $abilities, array $history): array
     882        {
     883                $allowed = [];
     884                foreach ($abilities as $ability) {
     885                        if ($ability instanceof WP_Ability) {
     886                                $allowed[$ability->get_name()] = $ability;
     887                        }
     888                }
     889
     890                $sent = [];
     891                foreach ($tools as $tool) {
     892                        if ($tool instanceof WP_Ability) {
     893                                $sent[$tool->get_name()] = $tool;
     894                        }
     895                }
     896
     897                foreach ($this->catalog->loadedNames($history) as $name) {
     898                        if (! isset($sent[$name]) && isset($allowed[$name])) {
     899                                $sent[$name] = $allowed[$name];
     900                        }
     901                }
     902
     903                return array_values($sent);
    819904        }
    820905
    … …  
    10471132                        $digest = (string) wp_json_encode($responses[$index]->getResponse(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    10481133
    1049                         $calls[$index]['result'] = $isAnswer ? $digest : mb_substr($digest, 0, $digestLength);
     1134                        if ($isAnswer) {
     1135                                $calls[$index]['result'] = $digest;
     1136                                continue;
     1137                        }
     1138
     1139                        // A configuration the agent writes code against (e.g. a field
     1140                        // configuration) keeps the larger summary budget, so a follow-up
     1141                        // message can still use it instead of asking the user to paste it.
     1142                        if ($this->historyCompactor->isStickyTool((string) ($call['name'] ?? ''))) {
     1143                                $calls[$index]['result'] = mb_substr($digest, 0, max($digestLength, Constants::AI_LIST_RESULT_SUMMARY_LENGTH));
     1144                                continue;
     1145                        }
     1146
     1147                        // A nested result too long for the digest keeps its skeleton
     1148                        // rather than its first few hundred characters.
     1149                        $structureSummary = mb_strlen($digest) > $digestLength
     1150                                ? $this->historyCompactor->summarizeStructure($responses[$index]->getResponse())
     1151                                : null;
     1152
     1153                        $calls[$index]['result'] = $structureSummary ?? mb_substr($digest, 0, $digestLength);
    10501154                }
    10511155
    … …  
    11361240                }
    11371241
    1138                 $signature = $abilityName . '|' . md5((string) wp_json_encode($this->normalizeArgs($functionCall->getArgs() ?? [])));
     1242                $signature = $this->readSignature($abilityName, $functionCall->getArgs());
    11391243                $seen      = $callCounts[$signature] ?? 0;
    11401244
    … …  
    11701274
    11711275        /**
     1276         * Builds the repeat-guard signature of a read call.
     1277         *
     1278         * @param string $abilityName Ability name.
     1279         * @param mixed  $args        Call arguments.
     1280         *
     1281         * @return string
     1282         * @since 1.3.4
     1283         */
     1284        private function readSignature(string $abilityName, $args): string
     1285        {
     1286                return $abilityName . '|' . md5((string) wp_json_encode($this->normalizeArgs($args ?? [])));
     1287        }
     1288
     1289        /**
     1290         * Resets the repeat-guard count of reads whose latest result was elided.
     1291         *
     1292         * The guard exists to stop a model re-fetching data it still has. Once the
     1293         * compactor has elided that data, a repeat is the only way to get it back,
     1294         * so the signature starts counting from zero again.
     1295         *
     1296         * @param array<string, int> $callCounts  Per-run counts of read call signatures.
     1297         * @param array<string, int> $readIndexes History index of the latest result per signature.
     1298         * @param int                $watermark   History index below which results are elided.
     1299         *
     1300         * @return void
     1301         * @since 1.3.4
     1302         */
     1303        private function forgetElidedReads(array &$callCounts, array &$readIndexes, int $watermark): void
     1304        {
     1305                foreach ($readIndexes as $signature => $index) {
     1306                        if ($index < $watermark) {
     1307                                unset($callCounts[$signature], $readIndexes[$signature]);
     1308                        }
     1309                }
     1310        }
     1311
     1312        /**
    11721313         * Normalizes call arguments for signature comparison (recursively sorted keys).
    11731314         *
  • agent-mod/trunk/includes/Services/AI/AIOrchestratorService.php

    r3761762 r3775519  
    7474
    7575        /**
     76         * Ability catalog (category index for dynamic tool loading).
     77         *
     78         * @var AbilityCatalog
     79         * @since 1.3.4
     80         */
     81        private AbilityCatalog $abilityCatalog;
     82
     83        /**
    7684         * Constructor (PHP-DI autowired).
    7785         *
    … …  
    8189         * @param AIClientAdapter   $clientAdapter     WP AI Client adapter.
    8290         * @param ConfirmationStore $confirmationStore Pending write-action store.
     91         * @param AbilityCatalog    $abilityCatalog    Ability catalog.
    8392         *
    8493         * @since 1.0.0
    … …  
    8998                KnowledgeResolver $knowledgeResolver,
    9099                AIClientAdapter $clientAdapter,
    91                 ConfirmationStore $confirmationStore
     100                ConfirmationStore $confirmationStore,
     101                AbilityCatalog $abilityCatalog
    92102        ) {
    93103                $this->promptBuilder     = $promptBuilder;
    … …  
    96106                $this->clientAdapter     = $clientAdapter;
    97107                $this->confirmationStore = $confirmationStore;
     108                $this->abilityCatalog    = $abilityCatalog;
    98109        }
    99110
    … …  
    128139                $siteContext = $agent->autoIncludeSiteContext ? $this->knowledgeResolver->getSiteContext() : [];
    129140
    130                 $systemInstruction = $this->promptBuilder->buildSystemInstruction($agent, $siteContext);
    131                 $abilities = $this->abilityResolver->resolve($agent);
     141                // Before the tools are selected, and stored with the agent so a paused
     142                // run resumes with the same tool list.
     143                $agent->preloadedAbilities = $this->recentToolNames($history);
     144
     145                $toolset = $this->resolveToolset($agent);
     146
     147                $systemInstruction = $this->promptBuilder->buildSystemInstruction($agent, $siteContext, $toolset['catalog']);
    132148
    133149                $messages   = $this->mapHistoryToMessages($history);
    … …  
    137153                        $systemInstruction,
    138154                        $messages,
    139                         $abilities,
     155                        $toolset['abilities'],
    140156                        $agent->provider,
    141157                        $agent->model,
    … …  
    143159                        $requestId,
    144160                        $autoApprovedAbilities,
    145                         $agent->webSearchEnabled
     161                        $agent->webSearchEnabled,
     162                        $toolset['tools']
    146163                );
    147164
    … …  
    211228                $siteContext = $agent->autoIncludeSiteContext ? $this->knowledgeResolver->getSiteContext() : [];
    212229
    213                 $systemInstruction = $this->promptBuilder->buildSystemInstruction($agent, $siteContext);
    214                 $abilities         = $this->abilityResolver->resolve($agent);
     230                $toolset = $this->resolveToolset($agent);
     231
     232                $systemInstruction = $this->promptBuilder->buildSystemInstruction($agent, $siteContext, $toolset['catalog']);
    215233
    216234                $response = $this->clientAdapter->resume(
    217235                        $systemInstruction,
    218236                        $messages,
    219                         $abilities,
     237                        $toolset['abilities'],
    220238                        $agent->provider,
    221239                        $agent->model,
    … …  
    226244                        $executedCalls,
    227245                        $decision,
    228                         $answerPayload
     246                        $answerPayload,
     247                        $toolset['tools']
    229248                );
    230249
    … …  
    235254
    236255                return $response;
     256        }
     257
     258        /**
     259         * Resolves the abilities of a run and the tools sent up front.
     260         *
     261         * @param AgentConfig $agent The agent configuration.
     262         *
     263         * @return array{abilities: \WP_Ability[], tools: \WP_Ability[]|null, catalog: array<string, array{label: string, count: int}>}
     264         *         catalog is empty when every ability is sent as a tool.
     265         * @since 1.3.4
     266         */
     267        private function resolveToolset(AgentConfig $agent): array
     268        {
     269                $abilities = $this->abilityResolver->resolve($agent);
     270                $toolset   = $this->abilityResolver->selectTools($agent, $abilities);
     271
     272                $toolset['catalog'] = null === $toolset['tools'] ? [] : $this->abilityCatalog->categoryIndex($abilities);
     273
     274                return $toolset;
     275        }
     276
     277        /**
     278         * Ability names the agent used in the recent turns of the conversation.
     279         *
     280         * Covers the same turns whose tool digests are replayed to the model
     281         * (agent_mod_history_tool_digest_turns), so the tools behind the results the
     282         * model reads are loaded too. The history comes from the client; the names
     283         * are only used after being intersected with the allowed abilities.
     284         *
     285         * @param array<int, array<string, mixed>> $history Prior turns.
     286         *
     287         * @return string[]
     288         * @since 1.3.4
     289         */
     290        private function recentToolNames(array $history): array
     291        {
     292                /** This filter is documented in AIOrchestratorService::mapHistoryToMessages(). */
     293                $turns = max(0, (int) apply_filters('agent_mod_history_tool_digest_turns', 6));
     294
     295                $toolTurns = array_filter($history, static function ($turn): bool {
     296                        return is_array($turn) && ! empty($turn['toolCalls']) && is_array($turn['toolCalls']);
     297                });
     298
     299                $names = [];
     300
     301                foreach ($turns > 0 ? array_slice($toolTurns, -$turns) : [] as $turn) {
     302                        foreach ($turn['toolCalls'] as $call) {
     303                                $name = is_array($call) ? sanitize_text_field((string) ($call['name'] ?? '')) : '';
     304
     305                                if ('' !== $name && AbilityCatalog::FIND !== $name) {
     306                                        $names[] = $name;
     307                                }
     308                        }
     309                }
     310
     311                return array_slice(array_values(array_unique($names)), 0, 20);
    237312        }
    238313
  • agent-mod/trunk/includes/Services/AI/AbilityResolver.php

    r3770241 r3775519  
    1515
    1616use WP_Ability;
     17use AgentMod\Common\Constants;
    1718use AgentMod\Services\AI\DTO\AgentConfig;
    1819use AgentMod\Services\AskUserAbilityService;
     20use AgentMod\Services\SettingsService;
    1921
    2022defined('ABSPATH') || exit;
    … …  
    2224class AbilityResolver
    2325{
     26        /**
     27         * Name prefix of the MCP Adapter plugin's abilities, which are never resolved.
     28         *
     29         * @var string
     30         * @since 1.3.4
     31         */
     32        private const MCP_ADAPTER_PREFIX = 'mcp-adapter/';
     33
     34        /**
     35         * Settings service; supplies the tool loading strategy.
     36         *
     37         * @var SettingsService
     38         * @since 1.3.4
     39         */
     40        private SettingsService $settings;
     41
     42        /**
     43         * Constructor (PHP-DI autowired).
     44         *
     45         * @param SettingsService $settings Settings service.
     46         *
     47         * @since 1.3.4
     48         */
     49        public function __construct(SettingsService $settings)
     50        {
     51                $this->settings = $settings;
     52        }
     53
    2454        /**
    2555         * Resolves the abilities available to an agent.
    … …  
    3969                        ? $this->resolveSelected($agent->allowedAbilities)
    4070                        : array_values(wp_get_abilities());
     71
     72                // System abilities are added below and in selectTools() exactly when they
     73                // apply, never because they were selected or registered.
     74                $abilities = array_values(array_filter($abilities, static function ($ability): bool {
     75                        return ! $ability instanceof WP_Ability || self::isSelectable($ability);
     76                }));
    4177
    4278                // Ask/Plan modes only get read-only abilities; Pro keeps final say via the filter below.
    … …  
    6197
    6298        /**
     99         * Whether an ability can be part of an ability selection.
     100         *
     101         * Two kinds of abilities never are, so selection lists should not offer them
     102         * and resolve() drops them from every list:
     103         *
     104         *  - AgentMod's system abilities (Constants::SYSTEM_ABILITY_CATEGORY): part
     105         *    of how the agent works; AgentMod offers them when they apply.
     106         *  - The MCP Adapter's abilities: discover / get-info / execute are a proxy
     107         *    for every other ability. execute-ability would run any of them under
     108         *    its own name, past the per-name write confirmation, auto-approval and
     109         *    a PRO agent's allowlist; the agent calls abilities directly anyway.
     110         *
     111         * @param WP_Ability $ability The ability.
     112         *
     113         * @return bool
     114         * @since 1.3.4
     115         */
     116        public static function isSelectable(WP_Ability $ability): bool
     117        {
     118                return ! self::isSystemAbility($ability) && 0 !== strpos($ability->get_name(), self::MCP_ADAPTER_PREFIX);
     119        }
     120
     121        /**
     122         * Whether an ability is one of AgentMod's system abilities.
     123         *
     124         * @param WP_Ability $ability The ability.
     125         *
     126         * @return bool
     127         * @since 1.3.4
     128         */
     129        public static function isSystemAbility(WP_Ability $ability): bool
     130        {
     131                return Constants::SYSTEM_ABILITY_CATEGORY === $ability->get_category();
     132        }
     133
     134        /**
     135         * Splits the resolved abilities into what may run and what is sent as tools.
     136         *
     137         * Under dynamic tool loading only a small set goes out as tool definitions:
     138         * the system abilities (find-abilities, the interactive chat tools), the core abilities
     139         * (agent_mod_core_abilities), the abilities the user highlighted and those
     140         * preloaded from recent turns. Everything else is reached through
     141         * find-abilities, which loads its matches into the tool list during the run.
     142         * The executable set is the resolved set plus find-abilities, so the write
     143         * gate, auto-approval and read-only rules keep working on real ability names.
     144         *
     145         * Small sets (up to agent_mod_dynamic_tools_threshold abilities) and the
     146         * 'all' strategy send everything, as before.
     147         *
     148         * @param AgentConfig  $agent     The agent configuration.
     149         * @param WP_Ability[] $abilities Abilities from resolve().
     150         *
     151         * @return array{abilities: WP_Ability[], tools: WP_Ability[]|null} tools is null when every ability is sent.
     152         * @since 1.3.4
     153         */
     154        public function selectTools(AgentConfig $agent, array $abilities): array
     155        {
     156                $everything = ['abilities' => $abilities, 'tools' => null];
     157
     158                /**
     159                 * Filters the tool loading strategy of a run: 'dynamic' or 'all'.
     160                 *
     161                 * @param string      $strategy Strategy from the settings page.
     162                 * @param AgentConfig $agent    The agent configuration.
     163                 *
     164                 * @since 1.3.4
     165                 */
     166                $strategy = (string) apply_filters('agent_mod_tool_loading', $this->settings->getToolLoading(), $agent);
     167
     168                /**
     169                 * Filters the ability count up to which dynamic loading still sends every ability.
     170                 *
     171                 * @param int $threshold Ability count.
     172                 *
     173                 * @since 1.3.4
     174                 */
     175                $threshold = (int) apply_filters('agent_mod_dynamic_tools_threshold', Constants::AI_DYNAMIC_TOOLS_THRESHOLD);
     176
     177                $find = function_exists('wp_get_ability') ? wp_get_ability(AbilityCatalog::FIND) : null;
     178
     179                if ('dynamic' !== $strategy || count($abilities) <= $threshold || ! $find instanceof WP_Ability) {
     180                        return $everything;
     181                }
     182
     183                /**
     184                 * Filters the abilities that are always sent as tools under dynamic loading.
     185                 *
     186                 * Only names that are also allowed for the run are used.
     187                 *
     188                 * @param string[]    $names Ability names.
     189                 * @param AgentConfig $agent The agent configuration.
     190                 *
     191                 * @since 1.3.4
     192                 */
     193                $core = (array) apply_filters('agent_mod_core_abilities', [], $agent);
     194
     195                $initial = array_merge($core, $agent->emphasizedAbilities, $agent->preloadedAbilities);
     196
     197                $abilities   = array_values(array_filter($abilities, static function ($ability): bool {
     198                        return $ability instanceof WP_Ability;
     199                }));
     200                $abilities[] = $find;
     201
     202                usort($abilities, static function (WP_Ability $a, WP_Ability $b): int {
     203                        return strcmp($a->get_name(), $b->get_name());
     204                });
     205
     206                // Filtering the name-sorted set keeps the tool block in the same order
     207                // on every request, which provider prompt caching depends on.
     208                $tools = array_values(array_filter($abilities, static function (WP_Ability $ability) use ($initial): bool {
     209                        return self::isSystemAbility($ability) || in_array($ability->get_name(), $initial, true);
     210                }));
     211
     212                return ['abilities' => $abilities, 'tools' => $tools];
     213        }
     214
     215        /**
    63216         * Offers the interactive chat tools exactly when somebody can use them.
    64217         *
    65218         * ask-user is offered on an interactive channel; present-plan only there
    66219         * and in Plan mode; request-approval only there, in Execute mode with
    67          * manual approval. On such a channel they are added even when the agent
    68          * uses a "selected" ability list, because they are part of the chat itself
    69          * rather than site capabilities. Everywhere else (widgets, scheduled runs)
    70          * they are removed, even when "all" abilities were resolved: nobody would
    71          * see the questions or the review options.
    72          *
    73          * @param WP_Ability[] $abilities Resolved abilities.
     220         * manual approval. They are system abilities, so resolve() has already
     221         * dropped them from the list whether or not they were selected; on an
     222         * interactive channel they are added back here. Everywhere else (widgets,
     223         * scheduled runs) they stay out: nobody would see the questions or the
     224         * review options.
     225         *
     226         * @param WP_Ability[] $abilities Resolved abilities, without system abilities.
    74227         * @param AgentConfig  $agent     The agent configuration.
    75228         *
    … …  
    79232        private function applyInteractiveTools(array $abilities, AgentConfig $agent): array
    80233        {
    81                 $interactiveTools = [
    82                         AskUserAbilityService::NAME,
    83                         AskUserAbilityService::PRESENT_PLAN,
    84                         AskUserAbilityService::REQUEST_APPROVAL,
    85                 ];
    86 
    87                 $abilities = array_values(array_filter($abilities, static function ($ability) use ($interactiveTools): bool {
    88                         return ! ($ability instanceof WP_Ability && in_array($ability->get_name(), $interactiveTools, true));
    89                 }));
    90 
    91234                if (! $agent->interactive) {
    92235                        return $abilities;
  • agent-mod/trunk/includes/Services/AI/DTO/AgentConfig.php

    r3770241 r3775519  
    203203         */
    204204        public string $approvalMode = 'manual';
     205
     206        /**
     207         * Ability names loaded up front under dynamic tool loading.
     208         *
     209         * Set server-side by the orchestrator from the tools used in recent turns,
     210         * so a follow-up message ("now do the same for the other page") does not
     211         * have to search for them again. Carried on the DTO because the DTO is what
     212         * the confirmation store keeps for a paused run. Names are only ever
     213         * intersected with the allowed abilities, never trusted on their own.
     214         *
     215         * @var string[]
     216         * @since 1.3.4
     217         */
     218        public array $preloadedAbilities = [];
    205219
    206220        /**
  • agent-mod/trunk/includes/Services/AI/HistoryCompactor.php

    r3770241 r3775519  
    99 * re-uploaded verbatim on every later iteration, and screenshot attachments on
    1010 * the user message are re-uploaded too. This service compacts the copy of the
    11  * history that goes to the provider: old tool results are elided to short
    12  * stubs (their call id and name are preserved — providers require a response
    13  * for every outstanding call), and binary file parts are kept only on the most
    14  * recent user message. The canonical history kept for persistence and the
    15  * confirmation/resume flow is never modified.
     11 * history that goes to the provider: binary file parts are kept only on the
     12 * most recent user message, and once the history grows past a token threshold
     13 * old tool results are elided to short summaries (their call id and name are
     14 * preserved — providers require a response for every outstanding call). The
     15 * canonical history kept for persistence and the confirmation/resume flow is
     16 * never modified.
     17 *
     18 * Elision is threshold-based and sticky on purpose. Below the trigger nothing
     19 * is elided, so the model keeps every result it fetched. When the trigger is
     20 * crossed, results are elided oldest first down to a lower target and a
     21 * watermark records how far it went; later iterations elide exactly the same
     22 * messages until the next crossing. The sent prefix therefore stays byte-
     23 * identical between compactions, which keeps provider prompt caches warm —
     24 * a window sliding on every iteration would invalidate them each time.
    1625 *
    1726 * @package AgentMod
    … …  
    5059
    5160    /**
     61     * Keys kept at every depth in the structure summary of a nested result
     62     * (e.g. a field configuration tree), so its skeleton survives elision.
     63     *
     64     * @var string[]
     65     * @since 1.3.4
     66     */
     67    private const STRUCTURE_KEYS = [
     68        'id', 'ID', 'post_id', 'term_id', 'user_id', 'name', 'slug', 'title', 'label', 'status', 'post_type', 'taxonomy',
     69        'type', 'fieldType', 'fieldLabel', 'meta_box_id', 'meta_box_title', 'section_name', 'section_title', 'menu_slug',
     70        'options', 'multiple', 'required', 'default',
     71    ];
     72
     73    /**
     74     * Maximum length of a top-level string kept in a structure summary.
     75     *
     76     * @var int
     77     * @since 1.3.4
     78     */
     79    private const STRUCTURE_STRING_LENGTH = 200;
     80
     81    /**
     82     * Rough characters-per-token ratio used to estimate history size.
     83     *
     84     * @var int
     85     * @since 1.3.4
     86     */
     87    private const CHARS_PER_TOKEN = 4;
     88
     89    /**
     90     * Estimated token cost of one file part (image / PDF attachment).
     91     *
     92     * @var int
     93     * @since 1.3.4
     94     */
     95    private const FILE_PART_TOKENS = 1500;
     96
     97    /**
    5298     * Compacts a history for sending to the provider.
    5399     *
    54      * @param Message[] $history Conversation history (never mutated).
     100     * Pass the same $watermark variable on every iteration of one run: it
     101     * records up to which history index tool results have been elided, so
     102     * later calls elide the same messages and only move it on the next
     103     * threshold crossing.
     104     *
     105     * @param Message[] $history   Conversation history (never mutated).
     106     * @param int       $watermark History index below which tool results are elided; advanced in place.
    55107     *
    56108     * @return Message[] Compacted copy.
    57109     * @since 1.1.5
    58110     */
    59     public function compact(array $history): array
     111    public function compact(array $history, int &$watermark = 0): array
    60112    {
    61113        /**
    62          * Filters how many of the most recent tool-result messages are re-sent
    63          * verbatim; older ones are elided to stubs.
     114         * Filters how many of the most recent tool-result messages are always
     115         * re-sent verbatim, even once the history has been compacted.
    64116         *
    65117         * @param int $keep Number of tool-result messages kept verbatim.
    … …  
    68120         */
    69121        $keep = max(0, (int) apply_filters('agent_mod_history_keep_tool_results', Constants::AI_HISTORY_KEEP_TOOL_RESULTS));
     122
     123        /**
     124         * Filters the estimated history size (tokens) at which old tool
     125         * results start being elided.
     126         *
     127         * @param int $trigger Token threshold.
     128         *
     129         * @since 1.3.4
     130         */
     131        $trigger = max(0, (int) apply_filters('agent_mod_history_compact_trigger_tokens', Constants::AI_HISTORY_COMPACT_TRIGGER_TOKENS));
     132
     133        /**
     134         * Filters the estimated history size (tokens) a compaction elides
     135         * down to. Kept below the trigger so compactions stay rare.
     136         *
     137         * @param int $target Token target.
     138         *
     139         * @since 1.3.4
     140         */
     141        $target = min($trigger, max(0, (int) apply_filters('agent_mod_history_compact_target_tokens', Constants::AI_HISTORY_COMPACT_TARGET_TOKENS)));
    70142
    71143        $toolResultIndexes = [];
    … …  
    84156        }
    85157
    86         // Tool-result messages recent enough to stay verbatim. (array_slice
    87         // with -0 would return the whole array, so 0 is handled explicitly.)
    88         $verbatim = $keep > 0 ? array_flip(array_slice($toolResultIndexes, -$keep)) : [];
     158        // The first tool-result message recent enough to always stay verbatim.
     159        // (array_slice with -0 would return the whole array, so 0 is handled
     160        // explicitly.)
     161        $recent      = $keep > 0 ? array_slice($toolResultIndexes, -$keep) : [];
     162        $firstRecent = empty($recent) ? PHP_INT_MAX : (int) reset($recent);
    89163
    90164        // Only the most recent attachment-bearing user message keeps its files.
    … …  
    97171        foreach ($history as $index => $message) {
    98172            if (! $message instanceof Message) {
    99                 $compacted[] = $message;
    100                 continue;
    101             }
    102 
    103             if (in_array($index, $toolResultIndexes, true) && ! isset($verbatim[$index])) {
    104                 $compacted[] = $this->elideToolResults($message, $sticky[$index] ?? []);
     173                $compacted[$index] = $message;
     174                continue;
     175            }
     176
     177            // Everything below the watermark was elided by an earlier
     178            // compaction of this run and stays elided, so the prefix the
     179            // provider sees does not change between compactions.
     180            if ($index < $watermark && in_array($index, $toolResultIndexes, true)) {
     181                $compacted[$index] = $this->elideToolResults($message, $sticky[$index] ?? []);
    105182                continue;
    106183            }
    … …  
    110187                && $index !== $lastFileIndex
    111188            ) {
    112                 $compacted[] = $this->stripFiles($message);
    113                 continue;
    114             }
    115 
    116             $compacted[] = $message;
    117         }
    118 
    119         return $compacted;
     189                $compacted[$index] = $this->stripFiles($message);
     190                continue;
     191            }
     192
     193            $compacted[$index] = $message;
     194        }
     195
     196        $tokens = $this->estimateTokens($compacted);
     197
     198        if ($tokens < $trigger) {
     199            return array_values($compacted);
     200        }
     201
     202        // Over the threshold: elide the oldest results down to the target and
     203        // move the watermark past the last one elided.
     204        foreach ($toolResultIndexes as $index) {
     205            if ($tokens <= $target || $index >= $firstRecent) {
     206                break;
     207            }
     208
     209            if ($index < $watermark) {
     210                continue;
     211            }
     212
     213            $before            = $this->estimateTokens([$compacted[$index]]);
     214            $compacted[$index] = $this->elideToolResults($history[$index], $sticky[$index] ?? []);
     215            $tokens           -= $before - $this->estimateTokens([$compacted[$index]]);
     216            $watermark         = $index + 1;
     217        }
     218
     219        return array_values($compacted);
     220    }
     221
     222    /**
     223     * Estimates the token size of a history.
     224     *
     225     * A characters / 4 heuristic over text and JSON-encoded function call and
     226     * response parts; each file part counts as a fixed amount. Only used to
     227     * decide when to compact, so a rough figure is enough.
     228     *
     229     * @param Message[] $history Conversation history.
     230     *
     231     * @return int Estimated tokens.
     232     * @since 1.3.4
     233     */
     234    public function estimateTokens(array $history): int
     235    {
     236        $chars = 0;
     237        $files = 0;
     238
     239        foreach ($history as $message) {
     240            if (! $message instanceof Message) {
     241                continue;
     242            }
     243
     244            foreach ($message->getParts() as $part) {
     245                $type = $part->getType();
     246
     247                if ($type->isFile()) {
     248                    $files++;
     249                } elseif ($type->isFunctionCall()) {
     250                    $call   = $part->getFunctionCall();
     251                    $chars += null === $call ? 0 : strlen((string) wp_json_encode($call->getArgs())) + strlen((string) $call->getName());
     252                } elseif ($type->isFunctionResponse()) {
     253                    $response = $part->getFunctionResponse();
     254                    $chars   += null === $response ? 0 : strlen((string) wp_json_encode($response->getResponse()));
     255                } else {
     256                    $chars += strlen((string) $part->getText());
     257                }
     258            }
     259        }
     260
     261        return (int) ceil($chars / self::CHARS_PER_TOKEN) + $files * self::FILE_PART_TOKENS;
     262    }
     263
     264    /**
     265     * Whether an ability's results are exempt from elision.
     266     *
     267     * By default the Pro skill fetcher, the ask-user / request-approval
     268     * answers and the Native Custom Fields configuration reads: a field
     269     * configuration is what the agent writes code against, and losing it
     270     * mid-task sends the agent back to re-read it or to guess meta keys.
     271     *
     272     * @param string $abilityName Ability name.
     273     *
     274     * @return bool
     275     * @since 1.3.4
     276     */
     277    public function isStickyTool(string $abilityName): bool
     278    {
     279        return in_array($abilityName, $this->stickyTools(), true);
     280    }
     281
     282    /**
     283     * Returns the ability names whose results are never elided.
     284     *
     285     * @return string[]
     286     * @since 1.3.4
     287     */
     288    private function stickyTools(): array
     289    {
     290        /**
     291         * Filters the ability names whose most recent result per argument
     292         * identity is never elided from the in-loop history.
     293         *
     294         * @param string[] $stickyTools Ability names.
     295         *
     296         * @since 1.1.5
     297         */
     298        // The user's answers to ask-user / request-approval are their own input,
     299        // so they are never elided either.
     300        return (array) apply_filters(
     301            'agent_mod_history_sticky_tools',
     302            array_merge(
     303                [
     304                    'agent-mod-pro/get-skill-by-slug',
     305                    'native-custom-fields/get-post-meta-fields',
     306                    'native-custom-fields/get-term-meta-fields',
     307                    'native-custom-fields/get-user-meta-fields',
     308                    'native-custom-fields/get-options-page',
     309                ],
     310                AskUserAbilityService::PAUSING_TOOLS
     311            )
     312        );
    120313    }
    121314
    … …  
    138331    private function resolveStickyParts(array $history, array $toolResultIndexes): array
    139332    {
    140         /**
    141          * Filters the ability names whose most recent result per argument
    142          * identity is never elided from the in-loop history.
    143          *
    144          * @param string[] $stickyTools Ability names.
    145          *
    146          * @since 1.1.5
    147          */
    148         // The user's answers to ask-user / request-approval are their own input,
    149         // so they are never elided either.
    150         $stickyTools = (array) apply_filters(
    151             'agent_mod_history_sticky_tools',
    152             array_merge(['agent-mod-pro/get-skill-by-slug'], AskUserAbilityService::PAUSING_TOOLS)
    153         );
     333        $stickyTools = $this->stickyTools();
    154334
    155335        if (empty($stickyTools)) {
    … …  
    234414
    235415            // A list keeps its IDs and titles: losing them is what sent the
    236             // agent back to list the same posts again and again.
    237             $listSummary = $this->summarizeListResult($response->getResponse());
    238 
    239             $payload = null !== $listSummary
    240                 ? [
     416            // agent back to list the same posts again and again. A nested
     417            // result (e.g. a field configuration) keeps its skeleton.
     418            $listSummary      = $this->summarizeListResult($response->getResponse());
     419            $structureSummary = null === $listSummary ? $this->summarizeStructure($response->getResponse()) : null;
     420
     421            if (null !== $listSummary) {
     422                $payload = [
    241423                    'elided'       => true,
    242424                    'list_summary' => $listSummary,
    243425                    'note'         => __('Older list result reduced to IDs and titles to save context. Reuse these IDs; do not list the same items again.', 'agent-mod'),
    244                 ]
    245                 : [
     426                ];
     427            } elseif (null !== $structureSummary) {
     428                $payload = [
     429                    'elided'            => true,
     430                    'structure_summary' => $structureSummary,
     431                    'note'              => __('Older result reduced to its structure (names, types, labels) to save context. If you need the omitted details, call the same tool again.', 'agent-mod'),
     432                ];
     433            } else {
     434                $payload = [
    246435                    'elided'  => true,
    247436                    'summary' => mb_substr(
    … …  
    249438                        0,
    250439                        Constants::AI_TOOL_RESULT_STUB_LENGTH
    251                     ) . '… ' . __('(older tool result elided to save context; re-call the tool only if you truly need the full data again)', 'agent-mod'),
     440                    ) . '… ' . __('(older tool result elided to save context; if you need the full data, call the same tool again)', 'agent-mod'),
    252441                ];
     442            }
    253443
    254444            $parts[] = new MessagePart(
    … …  
    322512
    323513    /**
     514     * Reduces a nested tool result to its structural skeleton.
     515     *
     516     * Applies to results that hold nested arrays but are not ID lists, such
     517     * as a Native Custom Fields configuration (sections → fields → sub-fields).
     518     * Top-level scalars are kept (long strings cut short); below the top level
     519     * only identifying keys (name, type, fieldType, label, options …) are kept,
     520     * at any depth.
     521     *
     522     * @param mixed $payload Tool result.
     523     *
     524     * @return string|null JSON summary capped at AI_LIST_RESULT_SUMMARY_LENGTH, or null when the result has no nested structure.
     525     * @since 1.3.4
     526     */
     527    public function summarizeStructure($payload): ?string
     528    {
     529        if (! is_array($payload) || empty(array_filter($payload, 'is_array'))) {
     530            return null;
     531        }
     532
     533        $skeleton = $this->skeleton($payload, 0);
     534
     535        if (empty($skeleton)) {
     536            return null;
     537        }
     538
     539        return mb_substr(
     540            (string) wp_json_encode($skeleton, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
     541            0,
     542            Constants::AI_LIST_RESULT_SUMMARY_LENGTH
     543        );
     544    }
     545
     546    /**
     547     * Recursively keeps the structural keys of a nested array.
     548     *
     549     * @param array<mixed> $value Array to reduce.
     550     * @param int          $depth Current depth (0 = top level).
     551     *
     552     * @return array<mixed>
     553     * @since 1.3.4
     554     */
     555    private function skeleton(array $value, int $depth): array
     556    {
     557        $keep = array_flip(self::STRUCTURE_KEYS);
     558        $out  = [];
     559
     560        foreach ($value as $key => $item) {
     561            if (is_array($item)) {
     562                $child = $this->skeleton($item, $depth + 1);
     563
     564                if (! empty($child)) {
     565                    $out[$key] = $child;
     566                }
     567            } elseif (0 === $depth) {
     568                $out[$key] = is_string($item) ? mb_substr($item, 0, self::STRUCTURE_STRING_LENGTH) : $item;
     569            } elseif (is_int($key) || isset($keep[$key])) {
     570                $out[$key] = $item;
     571            }
     572        }
     573
     574        return $out;
     575    }
     576
     577    /**
    324578     * Whether a value is a non-empty list of items that carry an ID.
    325579     *
  • agent-mod/trunk/includes/Services/AI/KnowledgeResolver.php

    r3585895 r3775519  
    3434                        'wp_version'       => (string) get_bloginfo('version'),
    3535                        'language'         => (string) get_bloginfo('language'),
     36                        'php_version'      => PHP_VERSION,
    3637                ];
    3738        }
  • agent-mod/trunk/includes/Services/AI/PromptBuilder.php

    r3761762 r3775519  
    3737         * Builds the full system instruction for an agent.
    3838         *
    39          * @param AgentConfig            $agent       The agent configuration.
    40          * @param array<string, string>  $siteContext Site context (may be empty).
     39         * @param AgentConfig                                      $agent       The agent configuration.
     40         * @param array<string, string>                            $siteContext Site context (may be empty).
     41         * @param array<string, array{label: string, count: int}> $toolCatalog Categories of the abilities left to
     42         *                                                                      find under dynamic tool loading
     43         *                                                                      (AbilityCatalog::categoryIndex()); empty
     44         *                                                                      when every ability is sent as a tool.
    4145         *
    4246         * @return string
    4347         * @since 1.0.0
    4448         */
    45         public function buildSystemInstruction(AgentConfig $agent, array $siteContext = []): string
     49        public function buildSystemInstruction(AgentConfig $agent, array $siteContext = [], array $toolCatalog = []): string
    4650        {
    4751                $sections = [];
    … …  
    101105                $sections[] = __('Earlier tool results are summarized in the conversation history as "[Tools used this turn]" blocks. Trust those summaries: do not re-call the same read tool with the same arguments to retrieve data you already have.', 'agent-mod');
    102106
     107                if (! empty($toolCatalog)) {
     108                        $sections[] = $this->buildToolLoadingDirective($toolCatalog);
     109                }
     110
    103111                $emphasized = $this->resolveEmphasized($agent);
    104112                if (! empty($emphasized)) {
    … …  
    113121
    114122                return (string) apply_filters('agent_mod_system_prompt', $instruction, $agent);
     123        }
     124
     125        /**
     126         * Builds the directive for dynamic tool loading.
     127         *
     128         * Without it the model takes its short tool list for everything it can do
     129         * and tells the user a task is impossible. The category list costs a few
     130         * hundred tokens at most and tells the model what is worth searching for.
     131         *
     132         * @param array<string, array{label: string, count: int}> $toolCatalog Categories keyed by slug.
     133         *
     134         * @return string
     135         * @since 1.3.4
     136         */
     137        private function buildToolLoadingDirective(array $toolCatalog): string
     138        {
     139                $lines = [
     140                        sprintf(
     141                                /* translators: %s: find-abilities ability name. */
     142                                __('Only a few of your tools are loaded at the start. Whenever the loaded tools cannot do what the task needs, call %s with short English keywords first; the tools it returns are loaded for your next step. Never tell the user something cannot be done before searching for a tool. Tool categories you can search (slug: label, number of tools):', 'agent-mod'),
     143                                AbilityCatalog::FIND
     144                        ),
     145                ];
     146
     147                foreach ($toolCatalog as $slug => $category) {
     148                        $lines[] = sprintf('- %s: %s (%d)', '' !== $slug ? $slug : '-', $category['label'], $category['count']);
     149                }
     150
     151                return implode("\n", $lines);
    115152        }
    116153
    … …  
    331368                        'wp_version'       => __('WordPress version', 'agent-mod'),
    332369                        'language'         => __('Language', 'agent-mod'),
     370                        'php_version'      => __('PHP version', 'agent-mod'),
    333371                ];
    334372
    … …  
    341379                }
    342380
     381                // Without this the model treats the block as a hint and re-fetches the
     382                // same values with the site/environment info tools on every turn.
     383                $lines[] = __('This site context is current and authoritative. Use it directly; do not call tools only to retrieve these values again (for example the site name, URL, WordPress or PHP version).', 'agent-mod');
     384
    343385                return implode("\n", $lines);
    344386        }
  • agent-mod/trunk/includes/Services/AbilityRegistrarService.php

    r3770241 r3775519  
    2020use WP_Post;
    2121use WP_Query;
     22use WP_Taxonomy;
     23use WP_Term;
    2224use WP_Block_Patterns_Registry;
    2325use WP_Theme_JSON;
    … …  
    642644                        [
    643645                                'label'               => __('Get Post or Page', 'agent-mod'),
    644                                 'description'         => __('Returns a post raw block markup as html by post_id, plus its status and custom fields (meta). Use list-posts to find post_id first. For the same text change across many posts use preview-bulk-replace / bulk-replace-content instead of reading each post. The returned html can be modified and passed straight back to update-post as the html parameter. Navigation menus (wp_navigation, referenced by {"ref":ID} in wp:navigation blocks) have dedicated abilities: list-navigations / get-navigation / update-navigation.', 'agent-mod'),
     646                                'description'         => __('Returns a post raw block markup as html by post_id, plus its status, custom fields (meta) and assigned taxonomy terms (terms, keyed by taxonomy). Use list-posts to find post_id first. For the same text change across many posts use preview-bulk-replace / bulk-replace-content instead of reading each post. The returned html can be modified and passed straight back to update-post as the html parameter. Navigation menus (wp_navigation, referenced by {"ref":ID} in wp:navigation blocks) have dedicated abilities: list-navigations / get-navigation / update-navigation.', 'agent-mod'),
    645647                                'category'            => self::CATEGORY,
    646648                                'execute_callback'    => [$this, 'executeGetPost'],
    … …  
    679681                                                'html'      => ['type' => 'string'],
    680682                                                'meta'      => ['type' => 'object'],
     683                                                'terms'     => ['type' => 'object'],
    681684                                                'error'     => ['type' => 'string'],
    682685                                        ],
    … …  
    693696                        [
    694697                                'label'               => __('Create Post or Page', 'agent-mod'),
    695                                 'description'         => __('Creates a new post/page. Provide title and optionally html and meta. The html parameter must be complete, valid serialized block markup; it is parsed with parse_blocks() and re-serialized as-is — attribute/innerHTML consistency is NOT validated server-side. Returns the new post_id for use with get-post/update-post.', 'agent-mod'),
     698                                'description'         => __('Creates a new post/page. Provide title and optionally html, meta and terms (categories, tags or custom taxonomy terms; create missing terms first with create-terms). The html parameter must be complete, valid serialized block markup; it is parsed with parse_blocks() and re-serialized as-is — attribute/innerHTML consistency is NOT validated server-side. Returns the new post_id for use with get-post/update-post.', 'agent-mod'),
    696699                                'category'            => self::CATEGORY,
    697700                                'execute_callback'    => [$this, 'executeCreatePost'],
    … …  
    721724                                                ],
    722725                                                'meta'        => $this->metaWriteSchema(),
     726                                                'terms'       => $this->termWriteSchema(),
    723727                                        ],
    724728                                ],
    … …  
    745749                        [
    746750                                'label'               => __('Update Post or Page', 'agent-mod'),
    747                                 'description'         => __('Updates a post. Provide post_id plus any of html, title, post_status and meta; fields left out stay unchanged. The html parameter must be complete, valid serialized block markup; it is parsed with parse_blocks() and re-serialized as-is — attribute/innerHTML consistency is NOT validated server-side. To edit a navigation menu (wp_navigation) use update-navigation instead.', 'agent-mod'),
     751                                'description'         => __('Updates a post. Provide post_id plus any of html, title, post_status, meta and terms (categories, tags or custom taxonomy terms; create missing terms first with create-terms); fields left out stay unchanged. The html parameter must be complete, valid serialized block markup; it is parsed with parse_blocks() and re-serialized as-is — attribute/innerHTML consistency is NOT validated server-side. To edit a navigation menu (wp_navigation) use update-navigation instead.', 'agent-mod'),
    748752                                'category'            => self::CATEGORY,
    749753                                'execute_callback'    => [$this, 'executeUpdatePost'],
    … …  
    777781                                                ],
    778782                                                'meta'        => $this->metaWriteSchema(),
     783                                                'terms'       => $this->termWriteSchema(),
    779784                                        ],
    780785                                ],
    … …  
    23512356                        'html'      => $post->post_content,
    23522357                        'meta'      => $this->readPostMeta($post->ID, $input['meta_keys'] ?? null),
     2358                        'terms'     => $this->readPostTerms($post),
    23532359                ];
    23542360        }
    … …  
    23572363         * Execute callback for agent-mod/create-post.
    23582364         *
    2359          * @param mixed $input Input data (expects 'title', optional 'html', 'post_type', 'post_status', 'meta').
     2365         * @param mixed $input Input data (expects 'title', optional 'html', 'post_type', 'post_status', 'meta', 'terms').
    23602366         *
    23612367         * @return array<string, mixed>
    … …  
    24032409                }
    24042410
     2411                // Term checks need no post, so they run before anything is inserted.
     2412                $termWrites = $this->prepareTermWrites($postType, $input['terms'] ?? null);
     2413                if (is_wp_error($termWrites)) {
     2414                        return ['success' => false, 'error' => $termWrites->get_error_message()];
     2415                }
     2416
    24052417                // Meta capabilities can only be checked against an existing post, so a
    2406                 // post with meta starts as a draft: it can be rolled back without side
    2407                 // effects, and publish-time hooks (SEO, sharing) see the final meta.
    2408                 $hasMeta = ! empty($input['meta']);
     2418                // post with meta or terms starts as a draft: it can be rolled back without
     2419                // side effects, and publish-time hooks (SEO, sharing) see the final data.
     2420                $hasMeta   = ! empty($input['meta']);
     2421                $deferSave = $hasMeta || ! empty($termWrites);
    24092422
    24102423                // wp_insert_post() unslashes its input; without wp_slash() the JSON
    … …  
    24152428                                'post_content' => $serializedContent,
    24162429                                'post_type'    => $postType,
    2417                                 'post_status'  => $hasMeta ? 'draft' : $postStatus,
     2430                                'post_status'  => $deferSave ? 'draft' : $postStatus,
    24182431                        ]),
    24192432                        true
    … …  
    24262439                $postId = (int) $result;
    24272440
    2428                 if ($hasMeta) {
    2429                         $metaWrites = $this->prepareMetaWrites($postId, $input['meta']);
     2441                if ($deferSave) {
     2442                        $metaWrites = $this->prepareMetaWrites($postId, $input['meta'] ?? null);
    24302443                        if (is_wp_error($metaWrites)) {
    24312444                                wp_delete_post($postId, true);
    … …  
    24342447
    24352448                        $this->applyMetaWrites($postId, $metaWrites);
     2449
     2450                        // A new post only holds the default terms (e.g. Uncategorized), so
     2451                        // the requested terms always replace them.
     2452                        $termError = $this->applyTermWrites($postId, $termWrites, false);
     2453                        if (is_wp_error($termError)) {
     2454                                wp_delete_post($postId, true);
     2455                                return ['success' => false, 'error' => $termError->get_error_message()];
     2456                        }
    24362457
    24372458                        if ('draft' !== $postStatus) {
    … …  
    24552476         * Execute callback for agent-mod/update-post.
    24562477         *
    2457          * @param mixed $input Input data (expects 'post_id' plus any of 'html', 'title', 'post_status', 'meta').
     2478         * @param mixed $input Input data (expects 'post_id' plus any of 'html', 'title', 'post_status', 'meta', 'terms').
    24582479         *
    24592480         * @return array<string, mixed>
    … …  
    24702491                $hasStatus = ! empty($input['post_status']);
    24712492                $hasMeta   = ! empty($input['meta']);
    2472 
    2473                 if (! $hasHtml && ! $hasTitle && ! $hasStatus && ! $hasMeta) {
    2474                         return ['success' => false, 'error' => __('Nothing to update: provide at least one of html, title, post_status or meta.', 'agent-mod')];
     2493                $hasTerms  = ! empty($input['terms']);
     2494
     2495                if (! $hasHtml && ! $hasTitle && ! $hasStatus && ! $hasMeta && ! $hasTerms) {
     2496                        return ['success' => false, 'error' => __('Nothing to update: provide at least one of html, title, post_status, meta or terms.', 'agent-mod')];
    24752497                }
    24762498
    … …  
    25272549                }
    25282550
    2529                 // Meta first, so publish-time hooks fired by a status change see it.
     2551                $termWrites = $this->prepareTermWrites($post->post_type, $input['terms'] ?? null);
     2552                if (is_wp_error($termWrites)) {
     2553                        return ['success' => false, 'error' => $termWrites->get_error_message()];
     2554                }
     2555
     2556                // Meta and terms first, so publish-time hooks fired by a status change see them.
    25302557                $this->applyMetaWrites($postId, $metaWrites);
     2558
     2559                $termError = $this->applyTermWrites($postId, $termWrites, true);
     2560                if (is_wp_error($termError)) {
     2561                        return ['success' => false, 'error' => $termError->get_error_message()];
     2562                }
    25312563
    25322564                if (count($updateArgs) > 1) {
    … …  
    32333265
    32343266        /**
     3267         * Input schema shared by the create-post and update-post terms parameter.
     3268         *
     3269         * Term references are strings so one fixed item type works with every
     3270         * provider; numeric strings are read as term IDs.
     3271         *
     3272         * @return array<string, mixed>
     3273         * @since 1.3.4
     3274         */
     3275        private function termWriteSchema(): array
     3276        {
     3277                return [
     3278                        'type'        => 'array',
     3279                        'description' => __('Optional. Taxonomy terms (categories, tags, custom taxonomies) to assign, one item per taxonomy. Terms must already exist: create missing ones first with create-terms. Taxonomies left out keep their current terms.', 'agent-mod'),
     3280                        'items'       => [
     3281                                'type'       => 'object',
     3282                                'required'   => ['taxonomy', 'terms'],
     3283                                'properties' => [
     3284                                        'taxonomy' => [
     3285                                                'type'        => 'string',
     3286                                                'description' => __('Taxonomy slug, e.g. "category", "post_tag" or a custom taxonomy from list-taxonomies.', 'agent-mod'),
     3287                                        ],
     3288                                        'terms'    => [
     3289                                                'type'        => 'array',
     3290                                                'items'       => ['type' => 'string'],
     3291                                                'description' => __('Term IDs (as strings, e.g. "12"), slugs or names. Replaces the post\'s terms in this taxonomy; an empty list removes them all.', 'agent-mod'),
     3292                                        ],
     3293                                        'append'   => [
     3294                                                'type'        => 'boolean',
     3295                                                'description' => __('update-post only. true adds the terms and keeps the existing ones instead of replacing them. Default false.', 'agent-mod'),
     3296                                        ],
     3297                                ],
     3298                        ],
     3299                ];
     3300        }
     3301
     3302        /**
     3303         * Validates term write items and resolves every term reference to an ID.
     3304         *
     3305         * Nothing is written here, so callers can reject the whole request before
     3306         * touching the post. Missing terms are never created: wp_set_object_terms()
     3307         * would silently add a new term for any unknown name.
     3308         *
     3309         * @param string $postType Post type the terms are assigned to.
     3310         * @param mixed  $items    The 'terms' ability input.
     3311         *
     3312         * @return array<int, array{taxonomy: string, term_ids: int[], append: bool}>|WP_Error
     3313         * @since 1.3.4
     3314         */
     3315        private function prepareTermWrites(string $postType, $items)
     3316        {
     3317                if (empty($items)) {
     3318                        return [];
     3319                }
     3320
     3321                if (! is_array($items)) {
     3322                        return new WP_Error('invalid_terms', __('terms must be a list of {taxonomy, terms} objects.', 'agent-mod'));
     3323                }
     3324
     3325                $writes = [];
     3326                foreach ($items as $item) {
     3327                        $slug     = is_array($item) && isset($item['taxonomy']) ? sanitize_key((string) $item['taxonomy']) : '';
     3328                        $taxonomy = '' !== $slug ? get_taxonomy($slug) : false;
     3329
     3330                        if (! $taxonomy instanceof WP_Taxonomy) {
     3331                                return new WP_Error(
     3332                                        'invalid_terms',
     3333                                        /* translators: %s: taxonomy slug */
     3334                                        sprintf(__('The taxonomy "%s" is not registered on this site. Call list-taxonomies to see the available slugs.', 'agent-mod'), $slug)
     3335                                );
     3336                        }
     3337
     3338                        if (! is_object_in_taxonomy($postType, $taxonomy->name)) {
     3339                                return new WP_Error(
     3340                                        'invalid_terms',
     3341                                        /* translators: 1: taxonomy slug, 2: post type slug */
     3342                                        sprintf(__('The taxonomy "%1$s" is not registered for the post type "%2$s".', 'agent-mod'), $taxonomy->name, $postType)
     3343                                );
     3344                        }
     3345
     3346                        if (isset($writes[$taxonomy->name])) {
     3347                                return new WP_Error(
     3348                                        'invalid_terms',
     3349                                        /* translators: %s: taxonomy slug */
     3350                                        sprintf(__('The taxonomy "%s" is listed more than once. Put all of its terms in a single item.', 'agent-mod'), $taxonomy->name)
     3351                                );
     3352                        }
     3353
     3354                        if (! current_user_can($taxonomy->cap->assign_terms)) {
     3355                                return new WP_Error(
     3356                                        'terms_forbidden',
     3357                                        /* translators: %s: taxonomy slug */
     3358                                        sprintf(__('You are not allowed to assign terms in the "%s" taxonomy.', 'agent-mod'), $taxonomy->name)
     3359                                );
     3360                        }
     3361
     3362                        if (! isset($item['terms']) || ! is_array($item['terms'])) {
     3363                                return new WP_Error(
     3364                                        'invalid_terms',
     3365                                        /* translators: %s: taxonomy slug */
     3366                                        sprintf(__('The "%s" item needs a terms list (an empty list removes all of its terms).', 'agent-mod'), $taxonomy->name)
     3367                                );
     3368                        }
     3369
     3370                        $termIds = [];
     3371                        $missing = [];
     3372                        foreach ($item['terms'] as $reference) {
     3373                                if (! is_scalar($reference) || '' === trim((string) $reference)) {
     3374                                        continue;
     3375                                }
     3376
     3377                                $termId = $this->resolveTermReference(trim((string) $reference), $taxonomy->name);
     3378                                if (null === $termId) {
     3379                                        $missing[] = (string) $reference;
     3380                                        continue;
     3381                                }
     3382
     3383                                $termIds[] = $termId;
     3384                        }
     3385
     3386                        if (! empty($missing)) {
     3387                                return new WP_Error(
     3388                                        'terms_not_found',
     3389                                        sprintf(
     3390                                                /* translators: 1: comma-separated term references, 2: taxonomy slug */
     3391                                                __('These terms do not exist in the "%2$s" taxonomy: %1$s. Check them with list-terms, or create them first with create-terms.', 'agent-mod'),
     3392                                                implode(', ', $missing),
     3393                                                $taxonomy->name
     3394                                        )
     3395                                );
     3396                        }
     3397
     3398                        $writes[$taxonomy->name] = [
     3399                                'taxonomy' => $taxonomy->name,
     3400                                'term_ids' => array_values(array_unique($termIds)),
     3401                                'append'   => ! empty($item['append']),
     3402                        ];
     3403                }
     3404
     3405                return array_values($writes);
     3406        }
     3407
     3408        /**
     3409         * Resolves a term reference (ID, slug or name) to a term ID.
     3410         *
     3411         * A numeric reference is tried as an ID first, then as a slug, so numeric
     3412         * slugs such as a "2024" tag still resolve.
     3413         *
     3414         * @param string $reference Term ID, slug or name.
     3415         * @param string $taxonomy  Taxonomy slug.
     3416         *
     3417         * @return int|null Null when no term matches.
     3418         * @since 1.3.4
     3419         */
     3420        private function resolveTermReference(string $reference, string $taxonomy): ?int
     3421        {
     3422                if (ctype_digit($reference)) {
     3423                        $term = get_term((int) $reference, $taxonomy);
     3424                        if ($term instanceof WP_Term) {
     3425                                return (int) $term->term_id;
     3426                        }
     3427                }
     3428
     3429                $term = get_term_by('slug', sanitize_title($reference), $taxonomy);
     3430                if (! $term instanceof WP_Term) {
     3431                        $term = get_term_by('name', sanitize_text_field($reference), $taxonomy);
     3432                }
     3433
     3434                return $term instanceof WP_Term ? (int) $term->term_id : null;
     3435        }
     3436
     3437        /**
     3438         * Saves term writes validated by prepareTermWrites().
     3439         *
     3440         * @param int                                                              $postId      Post ID.
     3441         * @param array<int, array{taxonomy: string, term_ids: int[], append: bool}> $writes      Validated writes.
     3442         * @param bool                                                             $allowAppend Whether the items' append flag is honoured.
     3443         *
     3444         * @return WP_Error|null Null on success.
     3445         * @since 1.3.4
     3446         */
     3447        private function applyTermWrites(int $postId, array $writes, bool $allowAppend): ?WP_Error
     3448        {
     3449                foreach ($writes as $write) {
     3450                        // Integer IDs: wp_set_object_terms() reads strings as term names.
     3451                        $result = wp_set_object_terms($postId, $write['term_ids'], $write['taxonomy'], $allowAppend && $write['append']);
     3452                        if (is_wp_error($result)) {
     3453                                return $result;
     3454                        }
     3455                }
     3456
     3457                return null;
     3458        }
     3459
     3460        /**
     3461         * Reads the terms assigned to a post for get-post, keyed by taxonomy.
     3462         *
     3463         * Only taxonomies an editor can see (public or shown in the admin UI) are
     3464         * listed, which keeps internal ones such as wp_theme out of the result.
     3465         * Taxonomies with no terms are listed empty, so the agent also learns
     3466         * which taxonomies it can assign.
     3467         *
     3468         * @param WP_Post $post Post.
     3469         *
     3470         * @return array<string, array<int, array{id: int, name: string, slug: string}>>
     3471         * @since 1.3.4
     3472         */
     3473        private function readPostTerms(WP_Post $post): array
     3474        {
     3475                $result = [];
     3476                foreach (get_object_taxonomies($post->post_type, 'objects') as $taxonomy) {
     3477                        if (! $taxonomy->public && ! $taxonomy->show_ui) {
     3478                                continue;
     3479                        }
     3480
     3481                        $terms = get_the_terms($post, $taxonomy->name);
     3482
     3483                        $result[$taxonomy->name] = is_array($terms)
     3484                                ? array_map(static function (WP_Term $term): array {
     3485                                        return ['id' => (int) $term->term_id, 'name' => $term->name, 'slug' => $term->slug];
     3486                                }, $terms)
     3487                                : [];
     3488                }
     3489
     3490                return $result;
     3491        }
     3492
     3493        /**
    32353494         * Builds the get-post / update-post error for a missing post or a post_type mismatch.
    32363495         *
  • agent-mod/trunk/includes/Services/AskUserAbilityService.php

    r3770241 r3775519  
    2525
    2626use WP_Error;
     27use AgentMod\Common\Constants;
    2728
    2829defined('ABSPATH') || exit;
    … …  
    7677         * @since 1.3.1
    7778         */
    78         private const CATEGORY = 'agent-mod';
     79        private const CATEGORY = Constants::SYSTEM_ABILITY_CATEGORY;
    7980
    8081        /**
    … …  
    121122                $this->settings = $settings;
    122123
     124                add_action('wp_abilities_api_categories_init', [$this, 'registerCategories']);
    123125                add_action('wp_abilities_api_init', [$this, 'registerAbilities']);
     126        }
     127
     128        /**
     129         * Registers the system ability category.
     130         *
     131         * Shared with agent-mod/find-abilities (FindAbilitiesAbilityService).
     132         *
     133         * @return void
     134         * @since 1.3.4
     135         */
     136        public function registerCategories(): void
     137        {
     138                if (! function_exists('wp_register_ability_category')) {
     139                        return;
     140                }
     141
     142                wp_register_ability_category(
     143                        Constants::SYSTEM_ABILITY_CATEGORY,
     144                        [
     145                                'label'       => __('AgentMod — System', 'agent-mod'),
     146                                'description' => __('Tools that are part of how the AgentMod agent works, such as asking the user questions, requesting approval and finding other abilities. AgentMod offers them automatically when they apply; they are never selected per agent.', 'agent-mod'),
     147                        ]
     148                );
    124149        }
    125150
  • agent-mod/trunk/includes/Services/SettingsService.php

    r3770241 r3775519  
    260260
    261261        /**
     262         * Ability source of the settings-based (default) agent.
     263         *
     264         * Always 'all' since dynamic tool loading: the agent finds the abilities it
     265         * needs itself, so the settings page no longer offers a selection. A saved
     266         * 'selected' value from earlier versions is ignored on purpose. Agents that
     267         * carry their own ability list (e.g. PRO agents) still pass it explicitly.
     268         *
    262269         * @return string
    263270         * @since 1.1.0
    … …  
    265272        public function getAbilitySource(): string
    266273        {
    267                 $saved = trim((string) ($this->getSettings()['agent_mod_abilities']['ability_source'] ?? ''));
    268                 $value = '' !== $saved ? $saved : 'all';
    269                 return (string) $value;
    270         }
    271 
    272         /**
     274                return 'all';
     275        }
     276
     277        /**
     278         * Allowed abilities of the settings-based (default) agent.
     279         *
     280         * Always empty: see getAbilitySource(). Returning the stale saved list would
     281         * let a selection the settings page no longer shows leak into agents that
     282         * fall back to it.
     283         *
    273284         * @return string[]
    274285         * @since 1.1.0
    … …  
    276287        public function getAllowedAbilities(): array
    277288        {
    278                 $saved = $this->getSettings()['agent_mod_abilities']['allowed_abilities'] ?? [];
    279                 return is_array($saved) ? $saved : [];
     289                return [];
     290        }
     291
     292        /**
     293         * Tool loading strategy: 'dynamic' or 'all'.
     294         *
     295         * @return string
     296         * @since 1.3.4
     297         */
     298        public function getToolLoading(): string
     299        {
     300                $saved = (string) ($this->getSettings()['agent_mod_abilities']['tool_loading'] ?? '');
     301
     302                return in_array($saved, ['dynamic', 'all'], true) ? $saved : Constants::AI_TOOL_LOADING;
    280303        }
    281304
  • agent-mod/trunk/readme.txt

    r3770241 r3775519  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.3.3
     7Stable tag: 1.3.4
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    123123
    124124== Changelog ==
     125= 1.3.4 =
     126* Added: Dynamic tool loading, the new default. Instead of sending every ability with every request, the agent starts with a few tools and loads the abilities a task needs through the new `agent-mod/find-abilities` tool; what it finds stays available for the rest of the run, and tools used in the recent messages are loaded up front. On a site with about 100 abilities, the tool definitions sent with each request drop from about 23,000 to about 1,100 tokens, so even a "hello" costs a fraction of what it did. Write confirmation, auto-approval and the read-only rule of Ask and Plan modes work exactly as before. Agents with 15 or fewer abilities still get all of them up front. The new "Tool Loading" setting (Settings > Abilities) switches back to sending every ability.
     127* Changed: The "Ability Source" and "Allowed Abilities" settings are removed. The default agent can use every ability and finds the ones it needs; a selection saved in an earlier version is no longer applied.
     128* Changed: AgentMod's own chat tools (`ask-user`, `present-plan`, `request-approval`, `find-abilities`) moved to a separate "AgentMod — System" ability category. They are offered automatically where they apply and are no longer part of ability selections, where selecting or leaving them out never had an effect.
     129* Fixed: The MCP Adapter plugin's abilities (`discover-abilities`, `get-ability-info`, `execute-ability`) are no longer offered to the agent. `execute-ability` could run any other ability under its own name, so the confirmation dialog showed "execute-ability" instead of the actual action, and a session approval for it covered every ability.
     130* Added: `create-post` and `update-post` can assign taxonomy terms (categories, tags and custom taxonomies) with the new `terms` parameter, one item per taxonomy. Terms are given by ID, slug or name and replace the post's terms in that taxonomy; an empty list removes them, and `append: true` on `update-post` adds to the existing ones. Taxonomies left out keep their terms. Missing terms are never created on the fly: the call fails and points to `create-terms`. The taxonomy, the post type and the user's permission to assign terms are checked before anything is saved, and terms are set before a post is published so publish-time hooks see them. Previously the agent could create terms but had no way to attach them to a post.
     131* Improved: `get-post` returns the post's assigned terms, grouped by taxonomy, including the taxonomies that have none, so the agent can see what is assigned and which taxonomies it can use.
     132* Improved: Older tool results are compacted only when the conversation grows large. Before, everything except the last four tool results was cut to a short stub on every step, even in a short run, so the agent lost data it had just fetched (for example a post type's custom field configuration) and had to ask for it or fetch it again. Now nothing is compacted until the history reaches about 80,000 tokens; it is then reduced to about 40,000, and the six most recent results are always kept in full.
     133* Improved: Compaction keeps the already compacted part of the history unchanged until the next compaction, so the providers' prompt cache (OpenAI, Gemini) is reused across steps instead of being invalidated on every step.
     134* Improved: A compacted nested result, such as a field configuration (sections, fields, sub-fields), keeps its structure (names, types, labels, options) instead of only its first 200 characters. The same summary is passed on to the following messages.
     135* Improved: Native Custom Fields configuration reads (`get-post-meta-fields`, `get-term-meta-fields`, `get-user-meta-fields`, `get-options-page`) are never compacted, and are passed on to the following messages with a larger summary.
     136* Fixed: A read-only tool whose earlier result had been compacted could not be called again, because the repeat guard counted it as an identical call. The agent then had neither the data nor a way to get it back. Such a call now runs again.
     137* Fixed: With "Enable site context" on, the agent still called `get-site-info` and `get-environment-info` on every request to fetch values that were already in the site context. The site context now includes the PHP version and tells the agent to use these values directly, and the default system prompt no longer asks for a tool call when the answer is already in the site context or the conversation.
     138
    125139= 1.3.3 =
    126140* Added: Bulk text replace in posts. `preview-bulk-replace` (read-only) shows, for up to 100 posts at once, how many matches each post has with a before / after snippet, without saving anything. `bulk-replace-content` then applies the same change on the server; changed posts get a revision when their post type supports revisions. Literal text or regular expressions, an optional limit per post (e.g. only the leading label), case-insensitive matching that handles the Turkish i / ı / İ / I, and by default only the visible text is changed, never block comments or attributes. A change that would alter the block structure skips that post. Previously a task such as "remove the 'Short bio:' label from these 12 posts" needed a get-post and an update-post per post, and ran into the full-content read limit and the tool-call limit.
Note: See TracChangeset for help on using the changeset viewer.