Skip to content

Commit 2aa81a2

Browse files
committed
feat: improve multi-step agentic workflows and add model benchmark suite
- Hide markdown-to-blocks and create-block-content from AI tool list (ai_hidden meta) — models were calling these instead of create-post, producing converted blocks that never got saved - Auto-detect markdown in create-post/update-post content and convert to Gutenberg blocks server-side via MarkdownToBlocks::convert() - Add featured_image_id parameter to create-post and update-post so models can set featured images from import-stock-image results - Add composite create-post-with-image ability that creates a post AND imports a stock image as featured image in a single tool call - Add ToolResultTruncator to prevent context bloat in long workflows — truncates large tool results (plugin lists, DB queries, HTML) before adding to conversation history while keeping full results in logs - Add cross-reference hint to import-stock-image results pointing models to use attachment_id as featured_image_id - Improve system prompt with explicit Content Creation section and instruction to call all needed tools in one response - Improve create-post tool description to emphasize it as the PRIMARY content creation tool with markdown auto-conversion - Add model benchmark suite (tests/benchmark/model-benchmark.mjs) that tests 7 WP admin prompts against synthetic.new models with automated scoring on tool selection, content quality, and argument validity Benchmark results: average score improved from 54% to 77% across 6 models on 7 WordPress admin operation prompts. Multi-step post+image task improved from 46% to 80%+ for all models.
1 parent fcf3ee9 commit 2aa81a2

7 files changed

Lines changed: 1874 additions & 40 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@ coverage-html/
1111
playwright-report/
1212
test-results/
1313
blob-report/
14+
# Benchmark results (contain API-specific data, regenerated on demand)
15+
tests/benchmark/results/

includes/Abilities/BlockAbilities.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ public static function register_abilities(): void {
6363
'permission_callback' => function () {
6464
return current_user_can( 'edit_posts' );
6565
},
66+
'meta' => [
67+
'show_in_rest' => false,
68+
'ai_hidden' => true,
69+
],
6670
]
6771
);
6872

@@ -249,6 +253,10 @@ public static function register_abilities(): void {
249253
'permission_callback' => function () {
250254
return current_user_can( 'edit_posts' );
251255
},
256+
'meta' => [
257+
'show_in_rest' => false,
258+
'ai_hidden' => true,
259+
],
252260
]
253261
);
254262

includes/Abilities/PostAbilities.php

Lines changed: 215 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
namespace GratisAiAgent\Abilities;
1414

15+
use GratisAiAgent\Models\MarkdownToBlocks;
1516
use WP_Error;
1617
use WP_Post;
1718

@@ -93,47 +94,51 @@ public static function register_abilities(): void {
9394
'ai-agent/create-post',
9495
[
9596
'label' => __( 'Create Post', 'gratis-ai-agent' ),
96-
'description' => __( 'Create a new WordPress post or custom post type entry. Supports title, content, excerpt, status, categories, tags, and meta fields.', 'gratis-ai-agent' ),
97+
'description' => __( 'Create a new WordPress post or page. This is the PRIMARY tool for creating any content — blog posts, landing pages, about pages, etc. Write content directly as HTML or markdown (auto-converted to Gutenberg blocks). Set post_type to "page" for pages or "post" for blog posts. Set status to "publish" to make it live immediately.', 'gratis-ai-agent' ),
9798
'category' => 'gratis-ai-agent',
9899
'input_schema' => [
99100
'type' => 'object',
100101
'properties' => [
101-
'title' => [
102+
'title' => [
102103
'type' => 'string',
103104
'description' => 'The post title.',
104105
],
105-
'content' => [
106+
'content' => [
106107
'type' => 'string',
107-
'description' => 'The post content (HTML or plain text).',
108+
'description' => 'The post content. Write in markdown (headings with ##, lists with -, bold with **) or HTML — markdown is automatically converted to Gutenberg blocks.',
108109
],
109-
'excerpt' => [
110+
'excerpt' => [
110111
'type' => 'string',
111112
'description' => 'Optional post excerpt.',
112113
],
113-
'status' => [
114+
'status' => [
114115
'type' => 'string',
115116
'description' => 'Post status: "draft" (default), "publish", "pending", "private", or "future".',
116117
'enum' => [ 'draft', 'publish', 'pending', 'private', 'future' ],
117118
],
118-
'post_type' => [
119+
'post_type' => [
119120
'type' => 'string',
120121
'description' => 'Post type (default: "post"). Use "page" for pages.',
121122
],
122-
'categories' => [
123+
'categories' => [
123124
'type' => 'array',
124125
'description' => 'Array of category IDs or names to assign.',
125126
'items' => [ 'type' => 'string' ],
126127
],
127-
'tags' => [
128+
'tags' => [
128129
'type' => 'array',
129130
'description' => 'Array of tag names to assign.',
130131
'items' => [ 'type' => 'string' ],
131132
],
132-
'meta' => [
133+
'featured_image_id' => [
134+
'type' => 'integer',
135+
'description' => 'Attachment ID to set as the featured image (e.g. from import-stock-image result).',
136+
],
137+
'meta' => [
133138
'type' => 'object',
134139
'description' => 'Key-value pairs of post meta to set.',
135140
],
136-
'site_url' => [
141+
'site_url' => [
137142
'type' => 'string',
138143
'description' => 'Subsite URL for multisite. Omit for the main site.',
139144
],
@@ -171,42 +176,46 @@ public static function register_abilities(): void {
171176
'input_schema' => [
172177
'type' => 'object',
173178
'properties' => [
174-
'post_id' => [
179+
'post_id' => [
175180
'type' => 'integer',
176181
'description' => 'The ID of the post to update.',
177182
],
178-
'title' => [
183+
'title' => [
179184
'type' => 'string',
180185
'description' => 'New post title.',
181186
],
182-
'content' => [
187+
'content' => [
183188
'type' => 'string',
184189
'description' => 'New post content.',
185190
],
186-
'excerpt' => [
191+
'excerpt' => [
187192
'type' => 'string',
188193
'description' => 'New post excerpt.',
189194
],
190-
'status' => [
195+
'status' => [
191196
'type' => 'string',
192197
'description' => 'New post status.',
193198
'enum' => [ 'draft', 'publish', 'pending', 'private', 'future', 'trash' ],
194199
],
195-
'categories' => [
200+
'categories' => [
196201
'type' => 'array',
197202
'description' => 'Replace categories with this array of IDs or names.',
198203
'items' => [ 'type' => 'string' ],
199204
],
200-
'tags' => [
205+
'tags' => [
201206
'type' => 'array',
202207
'description' => 'Replace tags with this array of names.',
203208
'items' => [ 'type' => 'string' ],
204209
],
205-
'meta' => [
210+
'featured_image_id' => [
211+
'type' => 'integer',
212+
'description' => 'Attachment ID to set as the featured image.',
213+
],
214+
'meta' => [
206215
'type' => 'object',
207216
'description' => 'Key-value pairs of post meta to update.',
208217
],
209-
'site_url' => [
218+
'site_url' => [
210219
'type' => 'string',
211220
'description' => 'Subsite URL for multisite. Omit for the main site.',
212221
],
@@ -279,6 +288,82 @@ public static function register_abilities(): void {
279288
},
280289
]
281290
);
291+
292+
wp_register_ability(
293+
'ai-agent/create-post-with-image',
294+
[
295+
'label' => __( 'Create Post with Stock Image', 'gratis-ai-agent' ),
296+
'description' => __( 'Create a WordPress post or page AND automatically import a stock image as the featured image — all in one step. Use this when you need to create content with an image.', 'gratis-ai-agent' ),
297+
'category' => 'gratis-ai-agent',
298+
'input_schema' => [
299+
'type' => 'object',
300+
'properties' => [
301+
'title' => [
302+
'type' => 'string',
303+
'description' => 'The post title.',
304+
],
305+
'content' => [
306+
'type' => 'string',
307+
'description' => 'The post content in markdown or HTML.',
308+
],
309+
'excerpt' => [
310+
'type' => 'string',
311+
'description' => 'Optional post excerpt / meta description.',
312+
],
313+
'status' => [
314+
'type' => 'string',
315+
'enum' => [ 'draft', 'publish', 'pending', 'private', 'future' ],
316+
'description' => 'Post status (default: draft).',
317+
],
318+
'post_type' => [
319+
'type' => 'string',
320+
'description' => 'Post type (default: post). Use page for pages.',
321+
],
322+
'categories' => [
323+
'type' => 'array',
324+
'items' => [ 'type' => 'string' ],
325+
'description' => 'Category names.',
326+
],
327+
'tags' => [
328+
'type' => 'array',
329+
'items' => [ 'type' => 'string' ],
330+
'description' => 'Tag names.',
331+
],
332+
'image_keyword' => [
333+
'type' => 'string',
334+
'description' => 'Search keyword for the stock image.',
335+
],
336+
'meta' => [
337+
'type' => 'object',
338+
'description' => 'Key-value pairs of post meta.',
339+
],
340+
'site_url' => [
341+
'type' => 'string',
342+
'description' => 'Subsite URL for multisite.',
343+
],
344+
],
345+
'required' => [ 'title', 'content', 'image_keyword' ],
346+
],
347+
'output_schema' => [
348+
'type' => 'object',
349+
'properties' => [
350+
'post_id' => [ 'type' => 'integer' ],
351+
'permalink' => [ 'type' => 'string' ],
352+
'status' => [ 'type' => 'string' ],
353+
'post_type' => [ 'type' => 'string' ],
354+
'featured_image' => [ 'type' => 'object' ],
355+
],
356+
],
357+
'meta' => [
358+
'annotations' => [ 'destructive' => false ],
359+
'show_in_rest' => true,
360+
],
361+
'execute_callback' => [ __CLASS__, 'handle_create_post_with_image' ],
362+
'permission_callback' => function (): bool {
363+
return current_user_can( 'edit_posts' ) && current_user_can( 'upload_files' );
364+
},
365+
]
366+
);
282367
}
283368

284369
/**
@@ -348,12 +433,13 @@ public static function handle_get_post( array $input ) {
348433
* @return array<string, mixed>|WP_Error
349434
*/
350435
public static function handle_create_post( array $input ) {
351-
$title = sanitize_text_field( $input['title'] ?? '' );
352-
$content = wp_kses_post( $input['content'] ?? '' );
353-
$excerpt = sanitize_textarea_field( $input['excerpt'] ?? '' );
354-
$status = sanitize_text_field( $input['status'] ?? 'draft' );
355-
$post_type = sanitize_text_field( $input['post_type'] ?? 'post' );
356-
$site_url = $input['site_url'] ?? '';
436+
$title = sanitize_text_field( $input['title'] ?? '' );
437+
$raw_content = $input['content'] ?? '';
438+
$content = wp_kses_post( self::maybe_convert_markdown( $raw_content ) );
439+
$excerpt = sanitize_textarea_field( $input['excerpt'] ?? '' );
440+
$status = sanitize_text_field( $input['status'] ?? 'draft' );
441+
$post_type = sanitize_text_field( $input['post_type'] ?? 'post' );
442+
$site_url = $input['site_url'] ?? '';
357443

358444
if ( empty( $title ) ) {
359445
return new WP_Error( 'ai_agent_empty_title', __( 'Post title is required.', 'gratis-ai-agent' ) );
@@ -409,6 +495,12 @@ public static function handle_create_post( array $input ) {
409495
wp_set_post_tags( $post_id, $tag_names );
410496
}
411497

498+
// Set featured image if provided.
499+
$featured_image_id = (int) ( $input['featured_image_id'] ?? 0 );
500+
if ( $featured_image_id > 0 ) {
501+
set_post_thumbnail( $post_id, $featured_image_id );
502+
}
503+
412504
// Set post meta.
413505
$meta = $input['meta'] ?? [];
414506
if ( ! empty( $meta ) && is_array( $meta ) ) {
@@ -478,7 +570,7 @@ public static function handle_update_post( array $input ) {
478570
$post_data['post_title'] = sanitize_text_field( $input['title'] );
479571
}
480572
if ( isset( $input['content'] ) ) {
481-
$post_data['post_content'] = wp_kses_post( $input['content'] );
573+
$post_data['post_content'] = wp_kses_post( self::maybe_convert_markdown( $input['content'] ) );
482574
}
483575
if ( isset( $input['excerpt'] ) ) {
484576
$post_data['post_excerpt'] = sanitize_textarea_field( $input['excerpt'] );
@@ -512,6 +604,12 @@ public static function handle_update_post( array $input ) {
512604
wp_set_post_tags( $post_id, $tag_names );
513605
}
514606

607+
// Update featured image if provided.
608+
$featured_image_id = isset( $input['featured_image_id'] ) ? (int) $input['featured_image_id'] : 0;
609+
if ( $featured_image_id > 0 ) {
610+
set_post_thumbnail( $post_id, $featured_image_id );
611+
}
612+
515613
// Update meta if provided.
516614
if ( isset( $input['meta'] ) && is_array( $input['meta'] ) ) {
517615
foreach ( $input['meta'] as $key => $value ) {
@@ -598,6 +696,96 @@ public static function handle_delete_post( array $input ) {
598696
];
599697
}
600698

699+
700+
/**
701+
* Handle the create-post-with-image composite ability.
702+
*
703+
* Creates a post AND imports a stock image as the featured image in one
704+
* atomic operation.
705+
*
706+
* @param array<string, mixed> $input Input with title, content, image_keyword, etc.
707+
* @return array<string, mixed>|WP_Error
708+
*/
709+
public static function handle_create_post_with_image( array $input ) {
710+
$image_keyword = sanitize_text_field( $input['image_keyword'] ?? '' );
711+
$image_result = null;
712+
713+
if ( ! empty( $image_keyword ) ) {
714+
$image_input = [
715+
'keyword' => $image_keyword,
716+
'site_url' => $input['site_url'] ?? '',
717+
];
718+
$image_result = StockImageAbilities::handle_import( $image_input );
719+
if ( is_array( $image_result ) && ! empty( $image_result['attachment_id'] ) ) {
720+
$input['featured_image_id'] = (int) $image_result['attachment_id'];
721+
}
722+
}
723+
724+
$post_result = self::handle_create_post( $input );
725+
726+
if ( is_wp_error( $post_result ) ) {
727+
return $post_result;
728+
}
729+
730+
if ( is_array( $image_result ) && ! empty( $image_result['attachment_id'] ) ) {
731+
$post_result['featured_image'] = [
732+
'attachment_id' => $image_result['attachment_id'],
733+
'url' => $image_result['url'] ?? '',
734+
];
735+
} elseif ( is_wp_error( $image_result ) ) {
736+
$post_result['featured_image_error'] = $image_result->get_error_message();
737+
}
738+
739+
return $post_result;
740+
}
741+
742+
/**
743+
* Detect whether content looks like markdown and convert to Gutenberg blocks.
744+
*
745+
* @param string $content Raw content from the model.
746+
* @return string Content ready for post_content (blocks HTML or original).
747+
*/
748+
private static function maybe_convert_markdown( string $content ): string {
749+
if ( '' === $content ) {
750+
return $content;
751+
}
752+
753+
if ( str_contains( $content, '<!-- wp:' ) ) {
754+
return $content;
755+
}
756+
757+
$html_block_tags = preg_match_all( '/<(?:p|h[1-6]|div|section|ul|ol|table|blockquote|figure|header|footer|article|nav)\b/i', $content );
758+
if ( $html_block_tags >= 3 ) {
759+
return $content;
760+
}
761+
762+
$markdown_signals = 0;
763+
if ( preg_match( '/^#{1,6}\s+\S/m', $content ) ) {
764+
++$markdown_signals;
765+
}
766+
if ( preg_match( '/\*{1,2}[^*\n]+\*{1,2}/', $content ) || preg_match( '/_{1,2}[^_\n]+_{1,2}/', $content ) ) {
767+
++$markdown_signals;
768+
}
769+
if ( preg_match( '/^[\-\*]\s+\S/m', $content ) ) {
770+
++$markdown_signals;
771+
}
772+
if ( preg_match( '/^\d+\.\s+\S/m', $content ) ) {
773+
++$markdown_signals;
774+
}
775+
if ( preg_match( '/\[[^\]]+\]\([^)]+\)/', $content ) ) {
776+
++$markdown_signals;
777+
}
778+
if ( str_contains( $content, '```' ) ) {
779+
++$markdown_signals;
780+
}
781+
782+
if ( $markdown_signals < 2 ) {
783+
return $content;
784+
}
785+
786+
return MarkdownToBlocks::convert( $content );
787+
}
788+
601789
/**
602790
* Resolve an array of category IDs or names to an array of IDs.
603791
*

includes/Abilities/StockImageAbilities.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ private static function download_and_import( string $keyword, int $width, int $h
197197
'url' => $attachment_url,
198198
'alt' => $title,
199199
'title' => $title,
200+
'tip' => 'Use this attachment_id as featured_image_id when calling create-post or update-post.',
200201
];
201202
}
202203
}

0 commit comments

Comments
 (0)