Repository navigation
Abilities API: Add a core/content-query ability - #12195
jorgefilipecosta wants to merge 99 commits into
Conversation
|
Hi there! 👋 Thank you for your contribution to WordPress! 💖 It looks like this is your first pull request to No one monitors this repository for new pull requests. Pull requests must be attached to a Trac ticket to be considered for inclusion in WordPress Core. To attach a pull request to a Trac ticket, please include the ticket's full URL in your pull request description. Pull requests are never merged on GitHub. The WordPress codebase continues to be managed through the SVN repository that this GitHub repository mirrors. Please feel free to open pull requests to work on any contribution you are making. More information about how GitHub pull requests can be used to contribute to WordPress can be found in the Core Handbook. Please include automated tests. Including tests in your pull request is one way to help your patch be considered faster. To learn about WordPress' test suites, visit the Automated Testing page in the handbook. If you have not had a chance, please review the Contribute with Code page in the WordPress Core Handbook. The Developer Hub also documents the various coding standards that are followed:
Thank you, |
Test using WordPress PlaygroundThe 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
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
87c0275 to
3cf4957
Compare
103a8d6 to
8123116
Compare
|
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 Core Committers: Use this line as a base for the props when committing in SVN: To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
|
Thanks @jorgefilipecosta! I went through this against the companion plugin PR WordPress/ai#739. The Core class is a truthful representation of what is proposed there. After accounting for the expected adaptations (no namespace, no On sequencing: the AI plugin PR should go first. Since #739 is still open, we are matching a proposal, not merged code, so this PR should wait on that merge to avoid the two drifting. One small thing to add: the Trac ticket reference is missing. There should already be one for this ability, like the sibling PRs have (users links #64657 and settings links #64605). Could you add it to the description and the test |
peterwilsoncc
left a comment
There was a problem hiding this comment.
I've added a few notes inline. LMK if this is no longer the canonical URL as there are a few linked to the ticket.
|
In addition to addressing the feedback from @peterwilsoncc, we need to sync the latest fixes applied in the AI plugin. Most importantly: |
core/content-query ability
670cd55 to
b8ffb86
Compare
get_post_type_capabilities() always sets the capabilities these checks read for registered post types, and the REST posts controller reads them directly too, so the guard isn't needed. This matches the change made in review of WordPress/wordpress-develop#12195.
get_post_type_capabilities() always sets the capabilities these checks read for registered post types, and the REST posts controller reads them directly too, so the guard isn't needed. This matches the change made in review of WordPress/wordpress-develop#12195.
WordPress/wordpress-develop#12195 now has the same ID lookup, post type check, shared schemas, author_slug filter and field, and title guard as the query here, so the markers that said what core does instead were wrong. Remove them, and put back core's wording in the comments they were added to. The class docblock now says that only the write abilities and the helpers they alone use are missing from core.
|
The previous review feedback is addressed, and local verification passed for the focused tests and the broader Abilities API suite on both single-site and multisite. Two follow-ups remain before approval:
For awareness, this PR incorporates the |
There was a problem hiding this comment.
I did a full pass over the class, in addition to my earlier comment. Permission handling looks solid. Every lookup is checked, raw fields need edit access, and filters that cannot be applied fail closed. I left a few inline notes:
- Before approval: limit
show_in_abilitiesto a boolean for now, and remove thecore/users-queryreferences (or land that ability first). - Question: the network-wide author lookup on multisite.
- Docs and minor: the future ability name, an explicit limit in the slug lookup, and the
@paramalignment.
get_permalink() reads the post's terms for `%category%` permalinks and its author for `%author%` permalinks. A query requesting `link` primed neither, so each returned post ran its own term or user query, up to 100 per page. `link` now primes the post meta and term caches, as the rendered excerpt and content do, and the author caches, as `author_slug` does. This matches WordPress/wordpress-develop#12195. The tests' query counter now loads the current user again after it flushes the cache. On multisite, capability checks load that user to check for a super admin, which would otherwise be counted as a query of the callback.
The comments said a GET request delivers list inputs as comma-separated strings and integers as strings, "until core sanitizes ability input itself". Since WordPress 7.1, the REST run controller converts input to the ability's input schema, so REST input arrives typed there. Schema validation still accepts integer and comma-separated strings, so a caller that bypasses the run controller, such as a direct WP_Ability::execute() call, can still pass them. The comments now say so, with core's wording from WordPress/wordpress-develop#12195, and a Plugin: note that a GET request on WordPress 7.0 can pass them too.
The rendered excerpt and content set the global post and call setup_postdata(), then restored only the global post. When the main query has no post, as in a REST request, wp_reset_postdata() does nothing, so `$id`, `$authordata`, `$currentday`, `$currentmonth`, `$page`, `$pages`, `$multipage`, `$more`, and `$numpages` kept the rendered post's data. When a post was set up before, running setup_postdata() for it again recomputed those globals instead of restoring them, undoing a template's `$more = 1`. set_up_post_context() now saves the loop globals before rendering, and restore_post_context() gives each its previous value back or unsets it again, as in WordPress/wordpress-develop#12195. When a post was set up before, setup_postdata() still runs for it first, so callbacks on `the_post` can restore their own globals.
Remove the tests that repeat others, and share one data provider of the inputs without a post type between the schema and the callback tests. Turn the loops over input values into data providers. Fix the checks that could not fail. The order test creates the posts out of date order, so a query ordered by ID fails it, the status filter test lists a draft next to the published post, and the checks that only expected an array compare the IDs of the posts returned. Compare lists of keys with `assertSameSets()`, check the exact keys of a post when fields are requested, cover `content.raw` and `content.rendered` as field paths, and rename the tests named after the removed single-post and query modes.
A `status` list that is given but holds no status fell through to the default statuses. With `ids` or `slugs`, those cover every status the current user may query, so an empty list widened the query instead of narrowing it, unlike an `ids` or `slugs` list with no valid entry, which fails as an invalid filter. Require at least one status in the input schema, and reject a list with no status left in the execute callback, as for `ids` and `slugs`.
Pin `minItems` on the `status` filter in the input schema test, and cover an empty list and a list without a status name in the test of the statuses the execute callback rejects.
…ors. Build the title, excerpt and content of a post from one table, as the dates are, instead of three copies of the same checks. Set the `id` without a check, since the requested fields always include it. Inline the not-found error at its only call site, keeping the note that a direct call to the execute callback is the only way to reach it. Describe what the `ids` filter does with a digit string beyond the integer range: it saturates, as REST sanitization does, rather than being dropped like a float too large to hold its integer.
Say that the totals can count posts the current user cannot read, such as another author's draft listed in `ids`, and that this is deliberate: a REST collection request with `include` and `status` reveals the same. Say why the inherited-parent read check stays, although no input can request `inherit`: a query filter can still add the status. Also note that posts in internal statuses cannot be fetched by ID. Give the default page size in the `per_page` description: 10, or 100 when `where.ids` or `where.slugs` is given.
Wrap rest_is_field_included() in an is_field_requested() method, used for every requested field path, and describe there how a whole field and its paths match, as the REST API's `_fields` parameter matches them.
…query`. Register a post type with its own capabilities, and check that the status gate, the private posts a query covers, and the author_slug lookup follow the capabilities of that post type rather than those of posts. Also cover a user who can read private posts but cannot edit posts.
0abd894 to
00349ab
Compare
…throw. Save the loop globals before the post is set up, and set it up inside the try block, so a `the_post` callback that throws while the post is set up no longer leaves the post and its loop globals in place after the ability returns its error. Also put the saved globals back when a `the_post` callback throws while the surrounding post is set up again.
Throw from `the_post` while the requested post is set up, and while the surrounding post is set up again, and check that the query fails with the callback exception error and that the surrounding loop globals are restored as they were.
e1b2fd6 to
0670065
Compare
Schema validation reads a scalar given for an array as a one-item list, so `where.slugs` sent as the number 2024 passed validation. The execute callback then dropped every list value that was not a string, and failed with `content_invalid_filter`. A scalar is now read as its string form, as schema validation reads it.
Query `where.slugs` as the number 2024, next to the comma-separated string and single value cases.
… schema. The Gemini API rejects `uniqueItems` in the `parameters` of a function declaration, which is where the Google provider plugin (1.2.0) sends an ability's input schema. Every Gemini request that offered the ability failed with a 400 before the model could call it. A repeated value is harmless: the execute callback already deduplicates IDs and slugs, and a repeated status or field path changes nothing. The REST API posts endpoint does not declare `uniqueItems` on its `include`, `slug` and `status` parameters either.
Check that the input schema declares no `uniqueItems` at any depth, and that a repeated ID, slug, status or field path returns the same result as the input without the repeat. The duplicate IDs and slugs cases leave the invalid input cases.
…t schema. This reverts d5b3624 and 19043a5. Without `uniqueItems`, the input schema qualifies for OpenAI's strict mode, which the OpenAI provider plugin (1.2.0) gets by default because it does not set `strict`. Strict mode treats every property as required, so the model filled every `where` filter with made-up values and never found the posts it asked for. Gemini still rejects `uniqueItems` through the Google provider plugin (1.2.0). The fix there is to send the schema as `parametersJsonSchema`.
|
I tested Snippet<?php
/*
* API keys for Settings > Connectors. Core reads these constants ahead of a key
* saved on that screen; leave one empty to use the saved key instead.
*/
define( 'ANTHROPIC_API_KEY', '' );
define( 'OPENAI_API_KEY', '' );
define( 'GOOGLE_API_KEY', '' );
/**
* Asks a provider a question that it answers by calling `core/content-query`.
*
* @param string $question The question.
* @param string $provider The AI Client provider ID, such as 'anthropic', 'openai' or 'google'.
* @param array $calls Receives the arguments of each ability call.
* @param string $model Receives the ID of the model that answered.
* @return string|WP_Error The answer, or the error.
*/
function my_plugin_ai_content_answer( string $question, string $provider, array &$calls = array(), string &$model = '' ) {
$resolver = new WP_AI_Client_Ability_Function_Resolver( 'core/content-query' );
$messages = array(
new \WordPress\AiClient\Messages\DTO\UserMessage(
array( new \WordPress\AiClient\Messages\DTO\MessagePart( $question ) )
),
);
for ( $i = 0; $i < 5; $i++ ) {
$result = wp_ai_client_prompt( $messages )
->using_provider( $provider )
->using_system_instruction( 'You are a WordPress assistant. Use abilities when needed.' )
->using_abilities( 'core/content-query' )
->generate_text_result();
if ( is_wp_error( $result ) ) {
return $result;
}
$model = $result->getModelMetadata()->getId();
// OpenAI returns each function call as its own candidate, so keep them all, not just toMessage().
$model_messages = array();
foreach ( $result->getCandidates() as $candidate ) {
$model_messages[] = $candidate->getMessage();
}
array_push( $messages, ...$model_messages );
$call_messages = array_filter( $model_messages, array( $resolver, 'has_ability_calls' ) );
if ( ! $call_messages ) {
return $result->toText();
}
foreach ( $call_messages as $message ) {
foreach ( $message->getParts() as $part ) {
if ( $part->getFunctionCall() ) {
$calls[] = $part->getFunctionCall()->getArgs();
}
}
$messages[] = $resolver->execute_abilities( $message );
}
}
return new WP_Error( 'ai_tool_loop_limit', 'Too many ability rounds.' );
}
// Demo: visit /?ai-content-test as an administrator or editor, or /?ai-content-test=google for one provider.
add_action(
'template_redirect',
function () {
if ( ! isset( $_GET['ai-content-test'] ) || ! current_user_can( 'edit_others_posts' ) ) {
return;
}
$providers = array( 'anthropic', 'openai', 'google' );
if ( in_array( $_GET['ai-content-test'], $providers, true ) ) {
$providers = array( $_GET['ai-content-test'] );
}
$prompts = array(
'How many published posts does this site have, and which are the three most recent? Give the title, author and date of each one.',
'Which posts are drafts or pending review? Give the title, status and author of each one.',
'Which pages have a parent page? Give the title of each one and of its parent.',
'What does the post with the slug hello-world say?',
);
set_time_limit( 600 );
header( 'Content-Type: text/plain; charset=utf-8' );
foreach ( $providers as $provider ) {
if ( ! \WordPress\AiClient\AiClient::defaultRegistry()->isProviderConfigured( $provider ) ) {
echo "## {$provider}: skipped, no API key or provider plugin\n\n";
continue;
}
foreach ( $prompts as $prompt ) {
$calls = array();
$model = '';
$answer = my_plugin_ai_content_answer( $prompt, $provider, $calls, $model );
echo "## {$provider}", $model ? " ({$model})" : '', ": {$prompt}\n";
foreach ( $calls as $args ) {
echo 'core/content-query ', wp_json_encode( $args ), "\n";
}
echo is_wp_error( $answer ) ? 'Error: ' . $answer->get_error_message() : $answer, "\n\n";
}
}
exit;
}
);To test Gemini, the Google provider needs WordPress/ai-provider-for-google#45 applied. Version 1.2.0 sends the schema in a format that rejects All the answers were correct. The site had 17 published posts, three drafts, two posts pending review and two child pages. The provider plugins came from WordPress.org: Anthropic 1.0.5, OpenAI 1.2.0 and Google 1.2.0 with the PR above.
Full outputTested and drafted with AI assistance (Claude Code). |
`build_post_fields()` always sets the post `id`, whether or not it was requested, so prepending `id` to the requested fields had no effect. Move the reason the `id` is always included to that method.
Remove tests and assertions that another test, or a stronger assertion in the same test, already covers, and provider cases for input shapes that were never shipped. Fold the smaller checks into the closest tests: the `title.rendered` path into the sub-field cases, the protected excerpt into the protected content test, the permalink filter into the post context test, and a parent of 0 into the shared slug test. Test the public status that is not viewable for every role in one test, measure author priming once, and compare empty results with an `EMPTY_RESULT` constant.
Remove the REST tests that only add a 200 status check to what the ability tests already cover, and the test for an `id` filter that was never shipped. Send the string values of the query string test in one request, and name the remaining 400 test after the input it sends.
gziolo
left a comment
There was a problem hiding this comment.
This looks ready to commit from my side. Thank you for working through all the iterations.
The input is now always { where, fields, pagination }, and the output is always { items, pagination }. This gives callers a clear and predictable shape. Field selection works well. Every item keeps its id. Raw fields the user cannot see are left out, but the readable post is still returned. Password-protected content stays protected.
The explicit show_in_abilities opt-in, the private callbacks, and the tests for permissions, input normalization, pagination, and restoring the post context address my earlier concerns.
A few small documentation improvements would help callers. These fit well in the schema descriptions:
- List the default fields (
id,type,status,date,slug,title.rendered), and clarify that omittingfieldsor passingfields: []uses them. - Explain that requested
rawsub-fields are left out when the user cannot edit the post, and that fields the post type does not support are left out too. So a readable item may contain onlyid. Distinguish this from password-protected posts, wherecontent.renderedandexcerpt.renderedare returned as an empty string, andprotectedtells callers why. - Document that
pagination.pagedefaults to1. A page past the end returns an error. The only exception is when the query matches no posts at all: then the response has emptyitemsand zero totals. Note that emptyitemsdoes not always mean zero totals, because posts the user cannot read are still counted.
None of these need API changes, so they can be done together with the developer documentation. As a separate follow-up, it would be good to add a _doing_it_wrong() notice when a post type with show_in_abilities is registered after the ability.
…t-query` run. Rendering a post calls setup_postdata(), which fires `the_post`. When the ability runs before any global post is set, for example on `init`, restore_post_context() unsets the loop globals again, but the action stays counted. Once WP::register_globals() sets the main post, get_the_content() called without a post, as the_content() outside the loop and the Post Content block do, reads the unset `$pages` instead of the post and throws a TypeError, which breaks the page. The count is reset only when the ability was the first thing to fire `the_post` and no global post was set. That puts back the state from before the call, so get_the_content() reads the post again. When a loop had already fired the action, or a post was set, the count is left alone.
Check that rendering a field with no global post leaves `the_post` uncounted, so get_the_content() reads a main post set afterwards, and that the count of a loop that had already started is kept.
… fields. List the default fields in the `fields` description, and say that a requested field is left out of a post it does not apply to, such as a raw sub-field the current user cannot edit or a field the post type does not support, so a post may hold only its `id`. Contrast this with password-protected posts, whose rendered excerpt and content are returned as empty strings. Say that `pagination.page` defaults to 1, that a query matching no post returns empty `items` and zero totals for any page, and that `items` can be empty while the totals count posts the current user cannot read.
|
Thank you for the review @gziolo, the documentation suggestions were applied to the schema descriptions. |
Adds a read-only `core/content-query` ability that returns a page of the posts of one post type, optionally filtered by IDs, slugs, status, author, or parent. Until now, Core had no ability to read posts. The code lives in the new private `WP_Abilities_Content` class, and the ability is registered in a new `content` category. Post types opt in with the new `show_in_abilities` argument of `register_post_type()`, which defaults to `false`. Only `true` exposes a post type, and the `post` and `page` post types set it. Developed in WordPress/ai#739 and #12195. Props jorgefilipecosta, gziolo, peterwilsoncc, ovidiu-galatan, justlevine, ugyensupport, dkotter, jason_the_adams, jasonbahl, jeffpaul. Fixes #66268. git-svn-id: https://develop.svn.wordpress.org/trunk@64388 602fd350-edb4-49c9-b593-d223f7449a82
Adds a read-only `core/content-query` ability that returns a page of the posts of one post type, optionally filtered by IDs, slugs, status, author, or parent. Until now, Core had no ability to read posts. The code lives in the new private `WP_Abilities_Content` class, and the ability is registered in a new `content` category. Post types opt in with the new `show_in_abilities` argument of `register_post_type()`, which defaults to `false`. Only `true` exposes a post type, and the `post` and `page` post types set it. Developed in WordPress/ai#739 and WordPress/wordpress-develop#12195. Props jorgefilipecosta, gziolo, peterwilsoncc, ovidiu-galatan, justlevine, ugyensupport, dkotter, jason_the_adams, jasonbahl, jeffpaul. Fixes #66268. Built from https://develop.svn.wordpress.org/trunk@64388 git-svn-id: http://core.svn.wordpress.org/trunk@63538 1a063a9b-81f0-0310-95a4-ce76da25c4cd
Part of WordPress/ai#40.
This PR adds the read-only
core/content-queryability to core. It ports the ability from the AI plugin (WordPress/ai#739, WordPress/ai#1002, and WordPress/ai#1025), with the shape from jorgefilipecosta#93.This PR implements the following changes:
core/content-queryability in a newcontentcategory.show_in_abilitiespost type argument. Only post types that set it totruecan be queried;postandpagedo.{ where, fields, pagination }, with no top-leveloneOforanyOf, which some AI providers reject.where.typeis required, and the optionalids,slugs,status,author_slug, andparentfilters combine with AND.{ items, pagination: { total, total_pages } }, newest posts first, 10 per page by default, or 100 withidsorslugs.rawfields are only returned for posts the user can edit. As in REST,pagination.totalcan count posts left out ofitems.Querying the posts of an author, two per page:
{ "items": [ { "id": 57, "type": "post", "status": "publish", "date": { "local": "2026-10-10T09:00:00+00:00", "gmt": "2026-10-10T09:00:00+00:00" }, "slug": "release-notes", "title": { "rendered": "Release notes" } }, { "id": 56, "type": "post", "status": "publish", "date": { "local": "2026-10-05T09:00:00+00:00", "gmt": "2026-10-05T09:00:00+00:00" }, "slug": "our-roadmap", "title": { "rendered": "Our roadmap" } } ], "pagination": { "total": 3, "total_pages": 2 } }Querying pages by slug, with some fields. With
idsorslugs, every status the user may query is included, so an administrator also gets the draft page:{ "items": [ { "id": 59, "status": "draft", "link": "http://localhost:8918/?page_id=59", "title": { "raw": "Contact", "rendered": "Contact" } }, { "id": 58, "status": "publish", "link": "http://localhost:8918/about/", "title": { "raw": "About", "rendered": "About" } } ], "pagination": { "total": 2, "total_pages": 1 } }The same query as a subscriber only returns the published page, without the
rawtitle:{ "items": [ { "id": 58, "status": "publish", "link": "http://localhost:8918/about/", "title": { "rendered": "About" } } ], "pagination": { "total": 1, "total_pages": 1 } }Trac ticket: https://core.trac.wordpress.org/ticket/66268
Testing
Verify unit tests are passing:
Open the post editor and run the examples above in the browser console, using an author and page slugs from your site.
Use of AI Tools
AI assistance: Yes. Claude Code was used to sync this PR with the plugin, apply the review feedback, test it, and draft this description; I reviewed the changes.
This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.