Skip to content

AI: Add automatic ability resolution loop to the WP AI Client - #12658

Draft
gziolo wants to merge 3 commits into
WordPress:trunkfrom
gziolo:add/64865-ability-resolution-loop
Draft

AI: Add automatic ability resolution loop to the WP AI Client#12658
gziolo wants to merge 3 commits into
WordPress:trunkfrom
gziolo:add/64865-ability-resolution-loop

Conversation

@gziolo

@gziolo gziolo commented Jul 23, 2026

Copy link
Copy Markdown
Member

Trac ticket: https://core.trac.wordpress.org/ticket/64865

What

Adds an automatic ability resolution loop to the WP AI Client, as proposed in the ticket. Today, when a model responds with an ability function call, developers must detect it, execute the ability, feed the result back, and re-prompt the model by hand. This PR makes that loop opt-in with one fluent method:

$text = wp_ai_client_prompt( 'What is this site about?' )
	->using_abilities( 'my-plugin/get-site-stats' )
	->using_ability_resolution( array( 'max_iterations' => 3 ) )
	->generate_text();

The method takes an options array so future options (for example a time limit or stop conditions) can be added without signature changes. max_iterations is the only option for now (default 5).

How it works

  • using_ability_resolution() enables the loop for generate_text_result() and generate_text(). Each round executes the ability calls requested by the model, appends the results to the conversation, and requests a follow-up response.
  • The loop ends when the model answers without ability calls, when it requests a function that is not a registered ability (the caller gets the round back to resolve custom functions), or when max_iterations is reached. The stop reason, the number of rounds, the resolved calls, and the full conversation are exposed under the ability_resolution key of the result's additional data.
  • Only abilities exposed to the model as ability function declarations, typically through using_abilities(), can be executed. Every call still runs the ability's permission check through WP_Ability::execute().
  • The wp_supports_ai() check and the wp_ai_client_prevent_prompt filter run before every round, so AI can be turned off mid-loop.
  • Token usage is summed across all rounds.
  • The AI client lifecycle events (wp_ai_client_before_generate_result / wp_ai_client_after_generate_result) fire for every round.

Note: a PHP AI Client change could simplify the internals

The PHP AI Client PromptBuilder does not expose its message list, and there is no method to append full messages to it (withHistory() prepends). Because of that, the first request here captures the sent messages and the resolved model from the BeforeGenerateResultEvent the builder dispatches, and later rounds call the captured model directly.

This only uses public API and works today without upstream changes. Still, a small addition to the PHP AI Client (for example a withMessages() append method) would let the loop run on the builder itself, remove the capture step, and give plugin developers a public way to build manual loops and multi-request conversations (see the report in ticket comment 2). If we agree on the direction, I will file an issue in the WordPress/php-ai-client repository.

Related known issue: the OpenAI provider plugin rejects Model-role messages sent back as input (Invalid value: 'output_text', also from ticket comment 2). That affects any multi-turn continuation, including this loop, and needs its own fix in the provider plugin.

Testing

npm run test:php -- --filter="Tests_AI_Client_AbilityResolution|Tests_AI_Client_AbilityFunctionResolver"

The new tests cover the happy path, transcript ordering and roles, multiple calls in one response, error and not-allowed responses fed back to the model, the iteration limit, unknown function calls, the prevent filter mid-loop, token usage aggregation, and lifecycle events.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings July 23, 2026 11:38
@github-actions

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props gziolo.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in automatic “ability resolution” loop to the WordPress AI Client prompt builder, allowing generate_text_result() / generate_text() to automatically execute model-requested ability calls and continue the conversation until completion or a stopping condition.

Changes:

  • Introduces using_ability_resolution() with configurable loop options (default max_iterations), and captures/extends the message transcript across rounds while aggregating token usage.
  • Adds a pre-resolve filter to short-circuit individual ability calls and an action for logging/auditing resolved calls.
  • Adds PHPUnit coverage for loop behavior (transcripts/roles, stopping reasons, token aggregation, prevent filter, lifecycle events) and extends test utilities with a scripted model.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php Implements the opt-in ability-resolution loop, options handling, transcript capture/continuation, and token aggregation.
src/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php Adds per-call pre-resolve short-circuiting and a resolved-call action hook for auditing.
tests/phpunit/includes/wp-ai-client-mock-model-creation-trait.php Adds a scripted text model helper to support multi-round loop tests.
tests/phpunit/tests/ai-client/wpAiClientAbilityResolution.php New test suite covering the resolution loop end-to-end and its metadata/edge cases.
tests/phpunit/tests/ai-client/wpAiClientAbilityFunctionResolver.php Adds tests for the new pre-resolve filter and resolved-call action behavior.
Comments suppressed due to low confidence (1)

src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php:441

  • call_builder() allows callers to invoke using_function_declarations() directly, which replaces the model-callable functions but leaves $this->resolvable_abilities untouched from any prior using_abilities() call. With ability resolution enabled, that stale allowlist can permit executing abilities that are no longer declared (e.g., via prompt-injected function names). Clearing $this->resolvable_abilities when using_function_declarations() is called keeps the execution allowlist aligned with what was actually exposed to the model.
		// Check if the prompt should be prevented for is_supported* and generate_*/convert_text_to_speech* methods.
		if ( self::is_support_check_method( $name ) || self::is_generating_method( $name ) ) {
			$prevented = $this->get_prompt_prevented_error();

			if ( null !== $prevented ) {

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php
Comment thread src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php
Copilot AI review requested due to automatic review settings July 23, 2026 11:55
@gziolo
gziolo force-pushed the add/64865-ability-resolution-loop branch from c10f3c8 to b15a0f5 Compare July 23, 2026 11:55

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (1)

tests/phpunit/tests/ai-client/wpAiClientAbilityFunctionResolver.php:925

  • This filter callback declares 0 parameters, but add_filter() will pass the filtered value as the first argument by default. To avoid argument count errors when the filter executes, accept the first argument (even if unused).
			static function () {
				return new WP_Error( 'vetoed', 'This call is not allowed.' );
			}

Comment thread src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php
Comment thread tests/phpunit/tests/ai-client/wpAiClientAbilityFunctionResolver.php Outdated
Comment thread tests/phpunit/tests/ai-client/wpAiClientAbilityResolution.php Outdated
Comment thread tests/phpunit/tests/ai-client/wpAiClientAbilityResolution.php Outdated
Copilot AI review requested due to automatic review settings July 23, 2026 12:01
@gziolo
gziolo force-pushed the add/64865-ability-resolution-loop branch from b15a0f5 to c5cafa3 Compare July 23, 2026 12:01
Introduces using_ability_resolution() on WP_AI_Client_Prompt_Builder. When
enabled, the text generation methods run a resolution loop: each round
executes the ability function calls requested by the model, appends the
results to the conversation, and requests a follow-up response, until the
model produces a final answer, requests an unknown function, or the
maximum number of rounds is reached.

Also adds the wp_ai_client_ability_resolution_defaults and
wp_ai_client_pre_resolve_ability_call filters and the
wp_ai_client_ability_call_resolved action.

See #64865.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@gziolo
gziolo force-pushed the add/64865-ability-resolution-loop branch from c5cafa3 to b629b4d Compare July 23, 2026 12:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread tests/phpunit/includes/wp-ai-client-mock-model-creation-trait.php
Copilot AI review requested due to automatic review settings July 23, 2026 12:04
@gziolo gziolo self-assigned this Jul 23, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php:343

  • using_ability_resolution() currently requires max_iterations to be a native int. That means common values like '2' (numeric strings) coming from filters/options will be treated as invalid and silently revert to 5, potentially increasing loop iterations unexpectedly. Consider normalizing with absint() for both filtered defaults and caller options, and only warning when the normalized value is < 1.
		// Guard against invalid filtered defaults.
		if ( ! is_int( $defaults['max_iterations'] ) || $defaults['max_iterations'] < 1 ) {
			$defaults['max_iterations'] = 5;
		}

tests/phpunit/includes/wp-ai-client-mock-model-creation-trait.php:243

  • create_scripted_text_generation_model() assumes $results is non-empty; if an empty array is passed, generateTextResult() will hit an undefined offset ($this->results[0]), making failures harder to diagnose. Add an explicit guard early with a clear exception message.
	protected function create_scripted_text_generation_model(
		array $results,
		array &$captured_prompts,
		?ModelMetadata $metadata = null
	): ModelInterface {
		$metadata = $metadata ?? $this->create_test_text_model_metadata();

Copilot AI review requested due to automatic review settings July 23, 2026 12:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment on lines +520 to +522
add_action( 'wp_ai_client_before_generate_result', $capture );
$result = $this->call_builder( 'generate_text_result', array() );
remove_action( 'wp_ai_client_before_generate_result', $capture );
Copilot AI review requested due to automatic review settings July 23, 2026 13:18

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php:522

  • The capture callback is added at the default priority (10). If any existing wp_ai_client_before_generate_result listener with an earlier priority triggers another AI request, this closure can capture the nested request’s event instead of the current one. Consider registering this capture handler at an extremely early priority to make the capture deterministic.
		add_action( 'wp_ai_client_before_generate_result', $capture );
		$result = $this->call_builder( 'generate_text_result', array() );
		remove_action( 'wp_ai_client_before_generate_result', $capture );

return $result;
}

if ( null === $captured || ! $captured->getModel() instanceof TextGenerationModelInterface ) {
Comment on lines +305 to +308
* Resolution follows the first response candidate and supports the
* generate_text_result() and generate_text() methods. Details about the loop
* are exposed under the `ability_resolution` key of the additional data of
* the final result.
@gziolo
gziolo marked this pull request as draft July 23, 2026 13:59
@JasonTheAdams

Copy link
Copy Markdown
Member

This is cool, @gziolo! Felix and I chatted about eventually adding something like this.

I wonder if it would be better to introduce agentic looping on function declarations in the PHP AI Client. We could add an event, overloadable function, or some such thing to make it possible for WP to handle Abilities.

What do you think?

@gziolo

gziolo commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Thank you for the feedback, @JasonTheAdams. I filed a new issue in the PHP AI client SDK:

I also drafted a PR with the initial proposal to keep the discussion going:

@dugyen

dugyen commented Aug 20, 2026

Copy link
Copy Markdown

Test Report

Ticket: Trac #64865

Result: PASS ✅ — verified end-to-end with a real mock model + the real Abilities API, not just linting.

Environment

  • WordPress: 7.1 (dev)
  • PHP: 8.5.7
  • Test method: WP-CLI wp eval-file running a real, end-to-end functional test. Built a scripted TextGenerationModelInterface mock model (adapted directly from the PR's own wp-ai-client-mock-model-creation-trait.php) plus a minimal fake ProviderInterface so using_model() + ProviderRegistry::bindModelDependencies() resolve without a live AI provider, then drove the loop through real, production wp_register_ability() / WP_Ability::execute() calls — no PHPUnit mocking, nothing stubbed on the code under test.
  • Database: SQLite

What the PR does

Adds an opt-in using_ability_resolution( array $options = array() ) method to WP_AI_Client_Prompt_Builder. When enabled, generate_text_result()/generate_text() run a loop instead of a single request: each round executes the ability calls the model requested, appends results to the conversation, and re-prompts, until the model answers without further calls, requests something outside the allowed abilities, or hits max_iterations (default 5). Aggregates token usage across rounds and exposes loop metadata (rounds, stop_reason, resolved_calls, messages) under ability_resolution in the result's additional data.

Steps taken

  1. Backed up both changed src/ files; confirmed the pre-patch state matched the same baseline used for AI: Catch Throwable in WP_AI_Client_Prompt_Builder so TypeErrors become WP_Error #12256/AI: Update PHP AI Client to 1.4.0 and introduce WP_AI_Client_Embedding_Builder #12530.
  2. Applied the patch (patch -p2); both files applied cleanly (the 3 test files were skipped — no local PHPUnit suite). php -l clean.
  3. Registered real abilities via wp_register_ability() on wp_abilities_api_init (category site, real execute_callback/permission_callback), so ability execution goes through the actual WordPress Abilities API.
  4. Built the scripted mock model + a minimal fake ProviderInterface so usingModel() resolves through the SDK's real provider-registry plumbing.
  5. Ran 6 scenarios covering the loop's full state machine.
  6. Regression-checked plain (non-resolution) prompt builder usage.
  7. Restored both files from backup (SHA-1 verified) and confirmed the test abilities never persisted beyond the PHP process.

Actual result — all 6 scenarios passed exactly as designed

  • Happy path: model requests wpaitest/get-site-name → the real ability executes (get_bloginfo('name') → "Test Site") → follow-up request → final text "The site is called Test Site." Model called exactly 2x. rounds=1, stop_reason=completed, resolved_calls=[{id:call-1, ability:wpaitest/get-site-name}].
  • Token aggregation: two rounds contributing TokenUsage(5,7,12) + TokenUsage(10,20,30) aggregated to exactly prompt=15, completion=27, total=42.
  • Unknown function requested: model calls a function that isn't a registered ability → loop stops immediately (unresolved_function_calls, rounds=0), no follow-up request, nothing executed.
  • max_iterations: capped at 2 → model called exactly 3x (initial + 2 rounds) → generate_text() returns WP_Error(ability_resolution_incomplete, stop_reason=max_iterations, rounds=2).
  • Prevent filter mid-loop: wp_ai_client_prevent_prompt starts returning true after the first check → loop stops between rounds with prompt_prevented, no ability runs after the block, only 1 model call made.
  • Ability outside the allow-list: model requests a real, registered ability that wasn't passed to using_abilities() on this builder → execute_ability() correctly refuses it (code: ability_not_allowed), the error is handed back to the model as a function response, and the loop recovers and completes normally on the next round — confirms the allow-list is enforced per-builder, not globally.
  • Ability execute_callback returns WP_Error: the error (code: test_failure) is correctly forwarded to the model as a function response and the loop recovers to a final answer, exactly like the not-allowed case.
  • No regression in plain (non-resolution) prompt-builder usage: fluent chaining, generate_text()'s existing no-provider error path, is_supported(), and calling an unsupported generation method (generate_image_result()) while resolution is enabled correctly _doing_it_wrong()-warns and runs without the loop, as documented.

This is the strongest test I could run for an AI Client PR so far — it exercises the actual production code path end-to-end (real Abilities API, real permission/execute callbacks, real token-usage math), not just static analysis or a hand-invoked private method.

Additional notes

Support content — full scenario output

=== TEST A: happy path ===
final text: The site is called Test Site.
rounds: 1, stop_reason: completed
resolved_calls: [{"id":"call-1","ability":"wpaitest/get-site-name"}]
model calls made: 2 (expect 2)
aggregated tokens: prompt=15 completion=27 total=42 (expect 15/27/42)

=== TEST B: unregistered function requested ===
model calls made: 1 (expect 1, no follow-up)
stop_reason: unresolved_function_calls

=== TEST C: max_iterations ===
code: ability_resolution_incomplete stop_reason: max_iterations rounds: 2
model calls made: 3 (expect 3: initial + 2 rounds)

=== TEST D: prevent filter mid-loop ===
code: prompt_prevented
model calls made: 1 (expect 1, follow-up prevented)

=== TEST E: ability not in this builder's allow-list ===
final text: Handled the not-allowed error
model calls made: 2
function response sent to model: {"error":"Ability \"wpaitest/other-ability\" was not specified in the allowed abilities list.","code":"ability_not_allowed"}

=== TEST F: ability execute_callback errors, loop recovers ===
final text: Recovered after ability error
model calls made: 2
function response sent to model: {"error":"Deliberate test failure.","code":"test_failure","data":null}

🤖 Test performed with Claude Code

@dugyen

dugyen commented Sep 2, 2026

Copy link
Copy Markdown

Test Report (independent re-verification)

Ticket: Trac #64865

Result: PASS ✅ — re-confirms the 2026-08-20 report with a freshly written test harness (not a re-run of the old script), since the PR has had no new commits since that report (last commit 2026-07-23).

Environment

  • WordPress: 7.1 (dev)
  • PHP: 8.5.7
  • Test method: real, end-to-end functional test via WP-CLI wp eval-file — not linting. Registered real abilities through wp_register_ability() (re-firing wp_abilities_api_init so the doing_action() gate is satisfied), drove the loop through real WP_Ability::execute() calls, and used a newly written scripted TextGenerationModelInterface mock model plus a minimal fake ProviderInterface (registered in a real ProviderRegistry) so usingModel() + ProviderRegistry::bindModelDependencies() resolve without a live AI provider. No PHPUnit mocking, nothing stubbed on the code under test.
  • Database: SQLite

Steps taken

  1. Backed up both changed src/ files (SHA-1 recorded); confirmed the pre-patch baseline was pristine.
  2. Fetched the PR diff fresh via gh pr diff, test-applied it in an isolated scratch repo, then copied the two patched files into the live install (the 3 test files under tests/phpunit/ skipped — no local PHPUnit suite wired up). php -l clean on both.
  3. Wrote a new wp eval-file harness from scratch: registered 3 real abilities with real execute_callback/permission_callback, built a scripted mock model + minimal fake provider, and exercised 7 scenarios (25 individual assertions).
  4. Restored both files from backup afterward; SHA-1 and full diff against the backup matched exactly.
  5. Confirmed the test abilities did not persist (a fresh wp eval call reports wp_has_ability('wpaitest/get-site-name') as false) and that no stray WP users were created.

Actual result — all 25 assertions across 7 scenarios passed

  • Happy path: model requests wpaitest/get-site-name → the real ability executes (get_bloginfo('name')) → follow-up request → final text produced. rounds=1, stop_reason=completed. Token usage aggregated across two scripted rounds (TokenUsage(5,7,12) + TokenUsage(10,20,30)) to exactly prompt=15, completion=27, total=42.
  • Unknown function requested: loop stops immediately (unresolved_function_calls, rounds=0), no follow-up request, nothing executed.
  • max_iterations: capped at 2 → model called exactly 3× (initial + 2 rounds) → generate_text() returns WP_Error(ability_resolution_incomplete, stop_reason=max_iterations, rounds=2).
  • Prevent filter mid-loop: wp_ai_client_prevent_prompt flips to true after the first check → loop stops between rounds with prompt_prevented, only 1 model call made.
  • Ability outside the allow-list: model requests a real, registered ability not passed to using_abilities() on this builder → correctly refused (code: ability_not_allowed), handed back to the model as a function response, loop recovers and completes normally — confirms the allow-list is enforced per-builder, not globally.
  • Ability execute_callback returns WP_Error: the error (code: test_failure) is correctly forwarded to the model as a function response and the loop recovers to a final answer.
  • No regression in plain (non-resolution) prompt-builder usage: fluent chaining, the existing no-provider error path, is_supported_for_text_generation() giving the identical answer with and without using_ability_resolution() enabled, and the _doing_it_wrong() warning for an unsupported generation method while resolution is enabled — all as documented.

Additional notes

  • No correctness issues found, matching the prior report. The Exception-only catch in generate_with_ability_resolution()'s follow-up-request try/catch remains a "should this be Throwable?" question shared with #12256 and #12530 — not reproduced concretely here either.
  • New since the last report: GitHub now shows this PR as mergeable: CONFLICTING against current trunk (a rebase issue; unrelated to the correctness of the tested logic, which is unchanged since 2026-07-23).
  • Core files backed up before patching and restored afterward; SHA-1 and full diff of both restored files matched the pre-test backups exactly. Test abilities were registered only in the WP_Abilities_Registry in-memory singleton for the lifetime of the wp eval-file process and never touched the database.

Test performed with Claude Code

@dugyen

dugyen commented Sep 2, 2026

Copy link
Copy Markdown

Code Review (multi-angle, high effort)

Ran an 8-angle review (line-by-line diff scan, removed-behavior audit, cross-file tracer, reuse, simplification, efficiency, altitude, conventions) over the two changed src/ files, each candidate independently re-verified against the code. 10 findings survived verification out of ~30 raw candidates; 1 candidate was refuted and dropped (see note at the bottom).

Correctness

  1. Uncaught exceptions from event listeners in resolution-loop rounds 2+src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php:615
    Round 1 runs through call_builder()'s try/catch (lines 429-446), which converts any exception thrown by a listener on wp_ai_client_before_generate_result/wp_ai_client_after_generate_result into a WP_Error. In generate_with_ability_resolution(), rounds 2+ dispatch those same two events directly (lines 615, 626) outside any try/catch — only the intervening $model->generateTextResult($transcript) call is guarded. A plugin's cost-tracking or moderation callback hooked to either event that throws during round 2+ propagates uncaught, producing a fatal error instead of the class's promised WP_Error.

  2. ability_resolution.resolved_calls lists calls that were rejected, not runclass-wp-ai-client-prompt-builder.php:604
    $ability_calls = array_filter($calls, [$resolver, 'is_ability_call']) (line 581) only checks the wpab__ name prefix, not the allow-list. execute_abilities() builds a FunctionResponse for every call regardless of outcome, and the foreach at line 604 unconditionally appends every one of those calls to $resolved_calls with no check of whether the response was actually an ability_not_allowed/ability_not_found error. Code auditing "which abilities the AI actually invoked" from this metadata will see an ability listed as resolved that in fact never executed.

  3. ability_resolution key silently missing when no abilities are declaredclass-wp-ai-client-prompt-builder.php:553
    using_ability_resolution()'s docblock unconditionally promises the result's additional data will carry an ability_resolution key. When $ability_names is empty (e.g. using_abilities() was never called), the method fires _doing_it_wrong() and returns the raw first-round result via to_generation_return_value() — which never reaches finish_ability_resolution(), the only place that writes that key. Caller code written against the documented contract hits an undefined-array-key warning in production, where the _doing_it_wrong() signal is invisible without WP_DEBUG.

  4. New _doing_it_wrong() fires on an already-erroring/prevented call chainclass-wp-ai-client-prompt-builder.php:364
    The new ability-resolution branch in __call() checks only $this->ability_resolution_options and the method name — not $this->error or the prevention filter, both of which are checked later inside call_builder(). Chaining ->using_ability_resolution()->generate_image_result() after an earlier failed call (or under a wp_ai_client_prevent_prompt block) now fires a warning that would have been silent pre-PR. Untested by the PR's own test_resolution_warns_for_unsupported_generation_methods, which only covers the clean-state case.

Correctness (plausible, lower confidence)

  1. "Read-only" filter clone actually shares live builder stateclass-wp-ai-client-prompt-builder.php:469
    wp_ai_client_prevent_prompt receives clone $this, docblocked as "read-only," but WP_AI_Client_Prompt_Builder defines no __clone(), so the clone's private $builder is the same underlying vendor PromptBuilder instance. A filter callback that calls any snake_case method on the "read-only" clone actually mutates the live builder. Pre-existing (extracted from prior inline code), but this PR now calls get_prompt_prevented_error() once per resolution round instead of once per top-level call, multiplying the exposure.

  2. Event-capture workaround can latch onto the wrong call's eventclass-wp-ai-client-prompt-builder.php:513
    The BeforeGenerateResultEvent capture used to recover the resolved model/messages (since the vendor PromptBuilder doesn't expose them) listens on the global wp_ai_client_before_generate_result hook with no per-invocation identity check. If a listener on wp_ai_client_prevent_prompt (checked synchronously just before this) or on the event itself triggers its own nested AI call, the capture can bind to that other call's event instead, misdirecting the whole resolution loop.

  3. max_iterations validation rejects valid floats/numeric stringsclass-wp-ai-client-prompt-builder.php:329
    is_int($options['max_iterations']) is strict — a float (3.0) or numeric string ('3') silently falls back to the default of 5, with only a debug-gated notice. No in-tree caller hits this today (all use int literals), so it's latent; the same file validates a comparable timeout option with a more lenient is_numeric() + cast pattern.

Altitude / Efficiency

  1. Follow-up rounds hand-duplicate the vendor SDK's generateResult() orchestrationclass-wp-ai-client-prompt-builder.php:619
    Rounds 2+ call $model->generateTextResult($transcript) directly, bypassing PromptBuilder::validateMessages() and getConfiguredModel()'s re-binding that round 1 (and every other generation path) goes through. Not exploitable today given the loop's own invariants, but any future vendor SDK change to those steps would silently apply only to round 1.

  2. Independent ability calls in one round execute sequentiallysrc/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php:199
    execute_abilities() is a plain foreach; the PR's own test_answers_all_calls_from_one_response shows a single model response can legitimately request multiple independent abilities. Since WP_Ability::execute() can perform arbitrary I/O, per-round latency is the sum rather than the max of each call's execution time.

Reuse

  1. get_function_calls() duplicates logic already in the resolver classclass-wp-ai-client-prompt-builder.php:668
    The new private method re-implements the exact getParts()/isFunctionCall()/getFunctionCall()/instanceof-guard extraction loop that WP_AI_Client_Ability_Function_Resolver already contains twice (has_ability_calls(), execute_abilities()). A shared extractor on the resolver (which already owns function-call semantics) could back all three call sites.

One additional candidate — a theorized allow-list collision via lossy wpab__ name-mangling for ability names containing __ — was checked and refuted: WP_Abilities_Registry::register() enforces ^[a-z0-9-]+/[a-z0-9-]+$ on ability names, so an underscore can never reach the resolver in the first place.


Reviewed with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants