Opened 6 weeks ago
Last modified 3 days ago
#64989 assigned enhancement
Abilities API: Add execution lifecycle filters to WP_Ability methods
| Reported by: |
|
Owned by: |
|
|---|---|---|---|
| Milestone: | 7.1 | Priority: | normal |
| Severity: | normal | Version: | 6.9 |
| Component: | Abilities API | Keywords: | abilities has-patch has-unit-tests |
| Focuses: | Cc: |
Description
The Abilities API (#64098, WordPress 6.9) provides two registration-phase filters (wp_register_ability_args, wp_register_ability_category_args) and two execution-phase actions (wp_before_execute_ability, wp_after_execute_ability). The execution actions are observation-only — no hook exists that lets plugins transform input, modify output, override permission decisions, or short-circuit execution.
This ticket proposes four new filters placed inside WP_Ability public methods and the execute() pipeline.
Background
Prior exploration
abilities-api#37 proposed four filters (ability_input_schema, ability_output_schema, ability_permission_result, ability_execute_result) placed inside read-only getters on WP_Ability. It was closed as "not planned" because getter-level filters fire many times during serialization (REST API schema responses, MCP tool listing, any introspection), introducing performance and behavioral unpredictability. Schema modification at read time also conflates registration-time concerns with execution-time concerns — registration-time customization is already handled by wp_register_ability_args.
This proposal targets operational methods instead — normalize_input(), check_permissions() — which fire at decision points, typically once per request. This follows the same approach as the validation filters proposed in #64311.
Demonstrated need from plugins
Two consumers of the Abilities API have independently built parallel hook systems because core lacks execution-time filters:
MCP Adapter (WordPress/mcp-adapter) implements mcp_adapter_-prefixed hooks for validation toggling, caching, and post-execution audit. The team has decided to maintain their own lower-level filters on top of whatever core provides (mcp-adapter#151), but richer core hooks would reduce the scope of what the adapter needs to re-implement.
AI Plugin (WordPress/ai) needs input transformation (prompt enrichment), output transformation (response formatting, safety filtering), and failure handling. ai#304 proposes wpai_-prefixed hooks for these concerns. Whether the AI plugin should add its own execution-lifecycle filters or rely on core is an open question this ticket aims to resolve.
Both plugins validate a three-tier extensibility pattern (core → protocol adapter → domain plugin) where richer core hooks reduce fragmentation.
Proposal
Four new filters. Two live inside public WP_Ability methods (normalize_input(), check_permissions()), ensuring consistent behavior across all call sites — execute(), REST API, WP-CLI. Two live on execute() itself because they govern orchestration flow. This mirrors how rest_pre_dispatch short-circuits the REST API dispatch pipeline.
Proposed execution lifecycle
┌─ wp_pre_execute_ability (filter) ──── can short-circuit │ ├─ validate_input() │ ├─ normalize_input() │ └─ wp_ability_normalize_input (filter) ── inside method │ ├─ check_permissions() │ ├─ permission_callback() │ └─ wp_ability_permission_result (filter) ── inside method │ ├─ wp_before_execute_ability (action) ── existing │ ├─ do_execute() │ ├─ wp_ability_execute_result (filter) ── transform output │ ├─ validate_output() │ ├─ wp_after_execute_ability (action) ── existing │ └─ return result
1. wp_pre_execute_ability — Short-circuit
$pre = apply_filters( 'wp_pre_execute_ability', null, $ability_name, $input, $ability );
if ( null !== $pre ) {
return $pre;
}
Fires in execute() before input validation. Returns non-null to bypass the entire pipeline. Modeled on rest_pre_dispatch.
Use cases: Cached responses, rate limiting, maintenance mode, test mocking.
2. wp_ability_normalize_input — Input transformation
// Inside WP_Ability::normalize_input(), after built-in normalization:
$input = apply_filters( 'wp_ability_normalize_input', $input, $ability_name, $ability );
Fires inside normalize_input() after the method's built-in logic. The filter receives schema-validated, normalized data. Returning a WP_Error halts execution.
Use cases: AI prompt enrichment, parameter defaulting beyond what JSON Schema handles, injecting caller metadata.
3. wp_ability_permission_result — Permission override
// Inside WP_Ability::check_permissions(), after permission_callback returns:
$permission = apply_filters( 'wp_ability_permission_result', $permission, $ability_name, $input, $ability );
Fires inside check_permissions() after permission_callback returns. check_permissions() is called from execute(), from the REST API permissions_check callback, and from WP-CLI's wp ability can-run — placing the filter inside the method ensures overrides apply consistently regardless of call site.
Use cases: MCP Adapter transport-level permission layering, multi-factor authorization, temporary permission elevation for batch operations.
4. wp_ability_execute_result — Output transformation
$result = apply_filters( 'wp_ability_execute_result', $result, $ability_name, $input, $ability );
Fires in execute() after do_execute() returns, before output validation. Filters must return data that conforms to output_schema — output validation remains the final integrity gate.
Use cases: AI response formatting, stripping internal metadata, content safety filtering, response enrichment.
Design principles
- Filters inside operational methods, not getters.
normalize_input()andcheck_permissions()fire at decision points, typically once per request — unlike the getters targeted in abilities-api#37. Placing filters here ensures consistent behavior across all call sites. - Validation remains authoritative. Input transformation fires after input validation; output transformation fires before output validation. Filters cannot bypass schema validation.
- Short-circuit stays on
execute().wp_pre_execute_abilitygoverns orchestration flow, not individual method behavior. - Naming follows established patterns.
wp_prefix per 6.9 convention.wp_ability_normalize_inputandwp_ability_permission_resultname the method and what they filter.wp_pre_execute_abilitymirrorsrest_pre_dispatch.
Related
- #64098 — Introduce Abilities API
- abilities-api#37 — Prior filter proposal (closed, lessons incorporated)
- #64311 — Validation hooks proposal (same pattern)
- ai#304 — AI plugin hook audit
- mcp-adapter#151 — MCP Adapter filters
Change History (6)
This ticket was mentioned in Slack in #core-ai by gziolo. View the logs.
6 weeks ago
This ticket was mentioned in PR #11731 on WordPress/wordpress-develop by @gziolo.
8 days ago
#4
- Keywords has-patch has-unit-tests added
## 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, three living *inside* their
owning WP_Ability methods (so they apply consistently across execute(), REST
permission checks, and WP-CLI), and one on execute() itself for orchestration
control.
## Filters added
| Filter | Where | Purpose |
|---|---|---|
wp_pre_execute_ability | top of execute() | Short-circuit. Returning non-null bypasses the entire pipeline (modeled on rest_pre_dispatch).
|
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 all call sites.
|
wp_ability_execute_result | inside execute(), after do_execute() | Transform the result before output validation. Can also recover from do_execute() 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, runs before output validation) → 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.
## Commits
This PR is split into four atomic commits, one per filter, in lifecycle order.
Reviewers can read commit-by-commit or as a single diff.
wp_ability_normalize_inputwp_ability_permission_resultwp_pre_execute_abilitywp_ability_execute_result
## Test plan
- [x]
npm run env:composer -- format— clean - [x]
npm run env:composer -- lint— clean - [x]
npm run env:composer -- compat— clean - [x]
npm run typecheck:php— 0 errors - [x]
npm run test:php -- --group=abilities-api— 263/263 passing - [x] Full PHPUnit suite — only unrelated pre-existing failure (
Tests_Theme_ThemeDir::test_broken_themes, fails on trunk too)
20 new tests cover:
- Each filter receives the documented parameters.
- Transformation use cases (input rewrite, permission override, result repair).
- Short-circuit semantics for
wp_pre_execute_ability(asserts no downstream filter, action, callback fires). - Ordering:
wp_ability_execute_resultruns before output validation and beforewp_after_execute_ability. - Failure handling:
wp_ability_execute_resultreceivingWP_Errorfromdo_execute(). wp_ability_permission_resultfiring whencheck_permissions()is called directly (REST/WP-CLI path).WP_Errorpropagation fromwp_ability_normalize_inputhalts the rest of the pipeline.
## Trac ticket
@migueluy commented on PR #11731:
8 days ago
#5
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 commented on PR #11731:
3 days ago
#6
Thanks — 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.
Moving tickets related to the Abilities API to a new sub-component.