Skip to content

Abilities API: add execution lifecycle filters to WP_Ability methods#11731

Open
gziolo wants to merge 11 commits into
WordPress:trunkfrom
gziolo:feature/abilities-execution-lifecycle-filters
Open

Abilities API: add execution lifecycle filters to WP_Ability methods#11731
gziolo wants to merge 11 commits into
WordPress:trunkfrom
gziolo:feature/abilities-execution-lifecycle-filters

Conversation

@gziolo
Copy link
Copy Markdown
Member

@gziolo gziolo commented May 6, 2026

Summary

Adds four new filters to WP_Ability to give plugins hook points across the execution
lifecycle. Today the only execution-phase hooks are observation-only actions
(wp_before_execute_ability, wp_after_execute_ability); plugins that need to
transform input, modify output, override permission decisions, or short-circuit
execution have no place to do that in core, and have built parallel hook systems
on top.

This PR closes that gap by introducing four filters that live inside their
owning WP_Ability methods (so they apply consistently across execute() and
direct callers, including REST permission checks), plus one orchestration-level
filter at the top of execute() for short-circuiting.

Filters added

Filter Where Purpose
wp_pre_execute_ability top of execute() Short-circuit. Returning a value other than the received default bypasses the entire pipeline. Uses a WP_Filter_Sentinel instance as the default so null, false, and arbitrary objects are valid short-circuit results.
wp_ability_normalize_input inside normalize_input() Transform input — prompt enrichment, parameter defaulting beyond JSON Schema, caller metadata injection. Returning WP_Error halts execution.
wp_ability_permission_result inside check_permissions() Override the registered permission_callback result. Applies consistently across execute() and direct check_permissions() callers.
wp_ability_execute_result inside do_execute() Transform the result before output validation. Can also recover from execute callback failures by returning a successful value in place of WP_Error.

Pipeline ordering inside execute()

wp_pre_execute_ability  (filter, can short-circuit)
  → normalize_input()   → wp_ability_normalize_input  (filter)
  → validate_input()
  → check_permissions() → wp_ability_permission_result (filter)
  → wp_before_execute_ability  (existing action)
  → do_execute()        → wp_ability_execute_result   (filter)
  → validate_output()
  → wp_after_execute_ability   (existing action)
  → return

Schema validation remains the final integrity gate: wp_ability_normalize_input
fires before validate_input(), and wp_ability_execute_result fires before
validate_output(). Filters cannot bypass schema validation except by
short-circuiting via wp_pre_execute_ability, where the caller takes
responsibility for the returned value's shape.

WP_Filter_Sentinel

Introduces a small, reusable marker class (src/wp-includes/class-wp-filter-sentinel.php)
loaded alongside WP_Hook in wp-includes/plugin.php. Each instance is unique by
identity, so callers can pass one as a filter's default value and compare returns
with === to detect "no filter modified this" — even when valid replacement values
include null, false, or arbitrary objects. Used by wp_pre_execute_ability,
and available for any future filter that needs the same pass-through detection.

REST behavior

WP_REST_Abilities_V1_Run_Controller::check_ability_permissions() now propagates
WP_Error results from normalize_input() directly instead of feeding them into
validate_input(). When the filter does not set its own status, the controller
defaults to 400; filter-set statuses (e.g., 422, 429) are preserved.

Test plan

  • npm run env:composer -- format — clean
  • npm run env:composer -- lint — clean
  • npm run env:composer -- compat — clean
  • npm run typecheck:php — 0 errors
  • PHPUnit Abilities suite — 1138 tests passing

New tests cover:

  • Each filter's transformation contract (input rewrite, permission grant/deny override, WP_Error recovery, result transform), with filter args verified via args-as-guards on the transformed value rather than side-effect captures.
  • Short-circuit semantics for wp_pre_execute_ability: pipeline configured to fail (permission denial) so the short-circuit value coming through cleanly proves the bypass.
  • null short-circuit preserved by the WP_Filter_Sentinel pattern.
  • Arbitrary object short-circuit values are not confused with the WP_Filter_Sentinel default.
  • Ordering: wp_ability_execute_result runs before output validation and before wp_after_execute_ability.
  • WP_Error propagation from wp_ability_normalize_input halts execution.
  • wp_ability_permission_result firing when check_permissions() is called directly.
  • REST: normalization filter errors default to status 400 and preserve filter-set statuses.

Trac ticket

https://core.trac.wordpress.org/ticket/64989

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code, Codex
Model(s): Claude Opus 4.7 (1M context)
Used for: Planning, implementation, unit tests, code review, commit messages, and PR description.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 6, 2026

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, westonruter, migueluy.

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

github-actions Bot commented May 6, 2026

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.

@gziolo gziolo force-pushed the feature/abilities-execution-lifecycle-filters branch 3 times, most recently from f59694c to 9b2aa9a Compare May 6, 2026 11:09
@lezama
Copy link
Copy Markdown

lezama commented May 6, 2026

This is super useful for any substrate that mediates ability execution on behalf of agents. Sharing four concrete use cases from that perspective so the filter shapes can be validated against them:

Filter Use case in an agent runtime
wp_pre_execute_ability Approval boundary: an agent runtime that ships a pending-action approval gate can hook here to short-circuit with an "approval required" envelope when a sensitive ability is invoked, instead of running. Also useful for contract tests: a recording harness can short-circuit with canned tool results so non-conversation behavior (provider call → tool call → provider call) gets locked into baselines without real execution.
wp_ability_normalize_input Agent context injection: the runtime can inject the current execution principal (calling agent ID, user, workspace, caller-chain context) into the ability's input. Abilities that need agent context don't have to receive it as an explicit parameter — it's normalized in by the substrate layer.
wp_ability_permission_result Tool access policy: an agent-level access policy (e.g. "this agent's bearer token doesn't authorize destructive abilities") can override the ability's registered permission_callback without modifying the ability itself. Cleaner than wrapping every ability with a delegating permission_callback.
wp_ability_execute_result Transcript / observability: tool calls + results get captured into the agent's transcript or routed to a telemetry sink without each ability having to opt in. The fact that this fires before validate_output() is exactly right — it preserves the integrity gate.

The "schema validation remains the final integrity gate" framing matches what a substrate wants downstream: filters can transform but can't bypass the contract.

One small thing I'd double-check: when the agent runtime hooks wp_pre_execute_ability and short-circuits with an approval envelope, the caller (an AgentConversationLoop or similar) needs to be able to distinguish "approval pending" from "ability returned a value" in the response shape. A typed sentinel value object would be clearest there, but with the current mixed return + sentinel approach the runtime can wrap with a typed value object on its side — the filter API doesn't need to bake that in.

cc / FYI: this would be consumed by Automattic/agents-api once it lands in core.

@gziolo
Copy link
Copy Markdown
Member Author

gziolo commented May 11, 2026

Thanks for the feedback. I'm glad the four shapes map cleanly to a substrate's needs. Keeping the short-circuit return as mixed is deliberate so consumers like agents-api can define their own envelope (approval-pending vs. value) without core picking a shape.

Comment thread src/wp-includes/abilities-api/class-wp-ability.php Outdated
*/
$pre_execute_sentinel = new stdClass();
$pre = apply_filters( 'wp_pre_execute_ability', $pre_execute_sentinel, $this->name, $input, $this );
if ( $pre !== $pre_execute_sentinel ) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Clever. I was thinking at first this wouldn't be good since it doesn't match filters like pre_option which work by using false as the sentinel value. But since the ability can return any value at all, then this wouldn't work.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I discovered the same pattern used in Customizer where similarly null is a valid output.

We still might need to iterate on it based in feedback shared in WordPress/ai#477 (reply in thread) by @ibrahimhajjaj:

The stdClass sentinel is fine in isolation but hard to compose. A filter callback can't hold a reference to the per-call sentinel, so all we have is instanceof stdClass. If two plugins both want "pass through, no opinion" they can't tell each other's sentinels apart from core's. Worth a line in the docblock maybe.

I'm considering a formal WP_Filter_Sentinel class to made it first-class concept removing the ambiguity. That would be similar to WP_Error on the naming level, but very specialized.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I discovered the same pattern used in Customizer where similarly null is a valid output.

Oh, yeah:

/*
* Check if the setting has a pre-existing value (an isset check),
* and if doesn't have any incoming post value. If both checks are true,
* then the preview short-circuits because there is nothing that needs
* to be previewed.
*/
$undefined = new stdClass();
$needs_preview = ( $undefined !== $this->post_value( $undefined ) );

I added that in e158ff2 😊

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Improved with 28f5f48.

Comment thread src/wp-includes/abilities-api/class-wp-ability.php Outdated
gziolo and others added 9 commits May 13, 2026 07:19
Introduces a new filter inside `WP_Ability::normalize_input()` that fires
after the method's built-in default-value handling, allowing plugins to
transform input — for example, prompt enrichment or parameter defaulting
beyond what JSON Schema handles. Returning a `WP_Error` halts execution.

`WP_Ability::execute()` now short-circuits when `normalize_input()` returns
a `WP_Error`, so a halt from the filter propagates as the execute result
without reaching `validate_input()`, `check_permissions()`, or the
`wp_before_execute_ability` action.

Props gziolo.
Fixes #64989.
Introduces a new filter inside `WP_Ability::check_permissions()` that fires
after the registered `permission_callback` returns. Plugins can use it to
enforce additional authorization rules — transport-level permission
layering for protocol adapters, multi-factor gates, or temporary
elevation for batch operations.

Because the filter lives inside the method, overrides apply consistently
across `execute()`, REST API permission checks, and WP-CLI call sites.
Filters can return `true`, `false`, or a `WP_Error`.

Props gziolo.
Fixes #64989.
Introduces a short-circuit filter at the top of `WP_Ability::execute()`,
modeled on `rest_pre_dispatch`. Returning a non-null value bypasses the
entire pipeline — `normalize_input()`, `validate_input()`,
`check_permissions()`, the registered `execute_callback`, output
validation, and the `wp_before_execute_ability` /
`wp_after_execute_ability` actions are all skipped, and the filter's
return value is returned to the caller as-is.

Useful for cached responses, rate limiting, maintenance mode, and test
mocking. Callers that short-circuit are responsible for input integrity
since input validation does not run.

Props gziolo.
Fixes #64989.
Introduces a new filter inside `WP_Ability::do_execute()` that fires after
the registered `execute_callback` returns. Plugins can use it to transform
the result — response formatting, stripping internal metadata, content
safety filtering, response enrichment — or to recover from an execution
failure by returning a successful value in place of a `WP_Error`.

Because `do_execute()` is invoked from `WP_Ability::execute()` between
`check_permissions()` and `validate_output()`, the transformed value is
still validated against `output_schema` before the
`wp_after_execute_ability` action fires. Placing the filter inside
`do_execute()` keeps each "result" filter consistent with
`wp_ability_normalize_input` and `wp_ability_permission_result`, which sit
inside the methods whose return values they filter.

Props gziolo.
Fixes #64989.
Use a unique stdClass sentinel for wp_pre_execute_ability, mirroring the Customizer symbol pattern, so returning null can intentionally short-circuit execution.
Co-authored-by: Weston Ruter <westonruter@gmail.com>
Co-authored-by: Weston Ruter <westonruter@gmail.com>
Adds a docblock note to the `wp_ability_permission_result` filter clarifying
that non-bool and non-WP_Error return values are coerced to `false`, plus a
test asserting the coercion contract so `check_permissions()` honors its
documented `bool|WP_Error` return type.

Follow-up to df03035.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gziolo gziolo force-pushed the feature/abilities-execution-lifecycle-filters branch from 09cd6ba to 1cbaab6 Compare May 13, 2026 05:30
gziolo and others added 2 commits May 13, 2026 07:38
Replaces the bare `stdClass` instance used as the `wp_pre_execute_ability`
filter default with a new `WP_Filter_Sentinel` final class. The previous
sentinel was indistinguishable from any other `stdClass` a filter callback
might return, making it hard for cooperating plugins to tell core's
"no opinion" marker from an unrelated `stdClass` short-circuit value.

`WP_Filter_Sentinel` is a general-purpose primitive added alongside
`WP_Error`. Identity comparison (`===`) against the original instance
remains the detection mechanism, and the existing pass-through contract
(return `$pre` unchanged) is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

3 participants