diff --git a/composer.json b/composer.json
index 85d2640..91a4771 100644
--- a/composer.json
+++ b/composer.json
@@ -1,12 +1,12 @@
{
"name": "lezama/openclawp",
- "description": "Generic chat-with-an-agent WordPress plugin built on Automattic/agents-api.",
+ "description": "Generic chat-with-an-agent WordPress plugin built on wordpress/agents-api.",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"require": {
"php": ">=8.1",
- "automattic/agents-api": "dev-main",
- "woocommerce/action-scheduler": "^3.9"
+ "woocommerce/action-scheduler": "^3.9",
+ "wordpress/agents-api": "dev-main"
},
"require-dev": {
"phpunit/phpunit": "^9.6",
@@ -37,9 +37,9 @@
"sort-packages": true
},
"scripts": {
- "test": "phpunit",
+ "test": "php -d auto_prepend_file=tests/phpunit-prepend.php vendor/phpunit/phpunit/phpunit",
"test:smoke": "php tests/smoke.php",
- "test:assembly": "phpunit --testsuite assembly",
+ "test:assembly": "php -d auto_prepend_file=tests/phpunit-prepend.php vendor/phpunit/phpunit/phpunit --testsuite assembly",
"lint:php": "phpcs",
"lint:php:fix": "phpcbf",
"analyse": "phpstan analyse"
diff --git a/composer.lock b/composer.lock
index 6af3f4d..a4f8bee 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,70 +4,148 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "c266c01738424e314a3dbcc52f54998b",
+ "content-hash": "2d0eae45ca434fb83de85de5703af42d",
"packages": [
{
- "name": "automattic/agents-api",
+ "name": "woocommerce/action-scheduler",
+ "version": "3.9.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/woocommerce/action-scheduler.git",
+ "reference": "c58cdbab17651303d406cd3b22cf9d75c71c986c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/woocommerce/action-scheduler/zipball/c58cdbab17651303d406cd3b22cf9d75c71c986c",
+ "reference": "c58cdbab17651303d406cd3b22cf9d75c71c986c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.5",
+ "woocommerce/woocommerce-sniffs": "0.1.0",
+ "wp-cli/wp-cli": "~2.5.0",
+ "yoast/phpunit-polyfills": "^2.0"
+ },
+ "type": "wordpress-plugin",
+ "extra": {
+ "scripts-description": {
+ "test": "Run unit tests",
+ "phpcs": "Analyze code against the WordPress coding standards with PHP_CodeSniffer",
+ "phpcbf": "Fix coding standards warnings/errors automatically with PHP Code Beautifier"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "GPL-3.0-or-later"
+ ],
+ "description": "Action Scheduler for WordPress and WooCommerce",
+ "homepage": "https://actionscheduler.org/",
+ "support": {
+ "issues": "https://github.com/woocommerce/action-scheduler/issues",
+ "source": "https://github.com/woocommerce/action-scheduler/tree/3.9.3"
+ },
+ "time": "2025-07-15T09:32:30+00:00"
+ },
+ {
+ "name": "wordpress/agents-api",
"version": "dev-main",
"source": {
"type": "git",
"url": "https://github.com/Automattic/agents-api.git",
- "reference": "cec9932e50a2d842b37e0ab1b5b1ec33a1d88562"
+ "reference": "600475b7ad2113b264d43fa67c1eda7af593284c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Automattic/agents-api/zipball/cec9932e50a2d842b37e0ab1b5b1ec33a1d88562",
- "reference": "cec9932e50a2d842b37e0ab1b5b1ec33a1d88562",
+ "url": "https://api.github.com/repos/Automattic/agents-api/zipball/600475b7ad2113b264d43fa67c1eda7af593284c",
+ "reference": "600475b7ad2113b264d43fa67c1eda7af593284c",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
+ "require-dev": {
+ "phpstan/phpstan": "^2.0",
+ "szepeviktor/phpstan-wordpress": "^2.0"
+ },
"suggest": {
"woocommerce/action-scheduler": "Required to actually run cron-triggered workflows. The substrate detects Action Scheduler at runtime and no-ops cleanly when absent; install this package (or any plugin that ships it, like WooCommerce) to enable scheduled workflow runs."
},
"default-branch": true,
"type": "wordpress-plugin",
+ "autoload": {
+ "files": [
+ "agents-api.php"
+ ]
+ },
"scripts": {
- "test": [
+ "phpstan": [
+ "phpstan analyse --no-progress --memory-limit=2G"
+ ],
+ "smoke": [
"php tests/bootstrap-smoke.php",
"php tests/message-envelope-smoke.php",
+ "php tests/message-coalesce-smoke.php",
"php tests/registry-smoke.php",
+ "php tests/installed-agent-materialization-smoke.php",
+ "php tests/registered-agent-materialization-adapter-smoke.php",
+ "php tests/runtime-agent-bundle-importer-smoke.php",
+ "php tests/package-lifecycle-smoke.php",
+ "php tests/package-capability-contract-smoke.php",
+ "php tests/package-adoption-orchestration-smoke.php",
"php tests/execution-principal-smoke.php",
"php tests/effective-agent-resolver-smoke.php",
"php tests/caller-context-smoke.php",
"php tests/authorization-smoke.php",
"php tests/agents-access-ability-smoke.php",
"php tests/agents-conversation-session-abilities-smoke.php",
+ "php tests/conversation-session-store-contract-smoke.php",
"php tests/action-policy-values-smoke.php",
"php tests/consent-policy-smoke.php",
"php tests/tool-policy-contracts-smoke.php",
+ "php tests/tool-source-registry-smoke.php",
+ "php tests/runtime-tool-policy-smoke.php",
+ "php tests/tool-tier-resolver-smoke.php",
"php tests/action-policy-resolver-smoke.php",
+ "php tests/ability-meta-abilities-smoke.php",
"php tests/tool-runtime-smoke.php",
"php tests/pending-action-store-contract-smoke.php",
"php tests/approval-resolver-contract-smoke.php",
"php tests/pending-action-abilities-smoke.php",
"php tests/identity-smoke.php",
"php tests/memory-metadata-contract-smoke.php",
+ "php tests/memory-store-resolver-smoke.php",
+ "php tests/ability-lifecycle-bridge-smoke.php",
+ "php tests/pre-execute-approval-smoke.php",
"php tests/approval-action-value-shape-smoke.php",
"php tests/workspace-scope-smoke.php",
"php tests/compaction-item-smoke.php",
"php tests/compaction-conservation-smoke.php",
"php tests/conversation-runner-contracts-smoke.php",
"php tests/conversation-transcript-lock-smoke.php",
+ "php tests/cpt-conversation-store-smoke.php",
"php tests/conversation-compaction-smoke.php",
"php tests/tool-pair-validator-smoke.php",
"php tests/markdown-section-compaction-smoke.php",
+ "php tests/spin-detector-smoke.php",
+ "php tests/identical-failure-tracker-smoke.php",
+ "php tests/tool-result-truncator-smoke.php",
"php tests/context-registry-smoke.php",
"php tests/conversation-loop-smoke.php",
+ "php tests/provider-turn-adapter-smoke.php",
"php tests/conversation-loop-tool-execution-smoke.php",
"php tests/conversation-loop-completion-policy-smoke.php",
"php tests/conversation-loop-transcript-persister-smoke.php",
"php tests/conversation-loop-events-smoke.php",
+ "php tests/conversation-loop-interrupt-source-smoke.php",
"php tests/iteration-budget-smoke.php",
"php tests/conversation-loop-budgets-smoke.php",
"php tests/channels-smoke.php",
+ "php tests/chat-run-control-smoke.php",
"php tests/frontend-chat-rest-smoke.php",
+ "php tests/agents-chat-jsonrpc-route-smoke.php",
"php tests/agents-dispatch-message-ability-smoke.php",
"php tests/webhook-safety-smoke.php",
"php tests/remote-bridge-smoke.php",
@@ -79,8 +157,12 @@
"php tests/workflow-lifecycle-smoke.php",
"php tests/agents-workflow-ability-smoke.php",
"php tests/routine-smoke.php",
+ "php tests/event-trigger-smoke.php",
"php tests/subagents-smoke.php",
"php tests/no-product-imports-smoke.php"
+ ],
+ "test": [
+ "@smoke"
]
},
"license": [
@@ -92,50 +174,7 @@
"issues": "https://github.com/Automattic/agents-api/issues",
"source": "https://github.com/Automattic/agents-api"
},
- "time": "2026-05-16T18:33:06+00:00"
- },
- {
- "name": "woocommerce/action-scheduler",
- "version": "3.9.3",
- "source": {
- "type": "git",
- "url": "https://github.com/woocommerce/action-scheduler.git",
- "reference": "c58cdbab17651303d406cd3b22cf9d75c71c986c"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/woocommerce/action-scheduler/zipball/c58cdbab17651303d406cd3b22cf9d75c71c986c",
- "reference": "c58cdbab17651303d406cd3b22cf9d75c71c986c",
- "shasum": ""
- },
- "require": {
- "php": ">=7.2"
- },
- "require-dev": {
- "phpunit/phpunit": "^8.5",
- "woocommerce/woocommerce-sniffs": "0.1.0",
- "wp-cli/wp-cli": "~2.5.0",
- "yoast/phpunit-polyfills": "^2.0"
- },
- "type": "wordpress-plugin",
- "extra": {
- "scripts-description": {
- "test": "Run unit tests",
- "phpcs": "Analyze code against the WordPress coding standards with PHP_CodeSniffer",
- "phpcbf": "Fix coding standards warnings/errors automatically with PHP Code Beautifier"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "GPL-3.0-or-later"
- ],
- "description": "Action Scheduler for WordPress and WooCommerce",
- "homepage": "https://actionscheduler.org/",
- "support": {
- "issues": "https://github.com/woocommerce/action-scheduler/issues",
- "source": "https://github.com/woocommerce/action-scheduler/tree/3.9.3"
- },
- "time": "2025-07-15T09:32:30+00:00"
+ "time": "2026-06-03T20:33:36+00:00"
}
],
"packages-dev": [
@@ -2817,7 +2856,7 @@
"aliases": [],
"minimum-stability": "dev",
"stability-flags": {
- "automattic/agents-api": 20
+ "wordpress/agents-api": 20
},
"prefer-stable": true,
"prefer-lowest": false,
diff --git a/docs/release.md b/docs/release.md
index 947dffc..058b5a5 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -11,7 +11,7 @@ Use this checklist before tagging or publishing a release package.
main plugin header.
5. Do not add `Requires Plugins: agents-api` unless `agents-api` is published
under that exact WordPress.org slug; core can load the Composer dependency
- itself from `vendor/automattic/agents-api/agents-api.php`.
+ itself from `vendor/wordpress/agents-api/agents-api.php`.
6. Run `composer update --lock` when dependency constraints change and commit
the resulting `composer.lock` so `dev-main` dependencies stay pinned.
7. While WordPress 7.0 is still pre-release, keep the README setup commands
@@ -51,7 +51,7 @@ For the default local model path, also verify a tool-using chat prompt in
Composer manifests, npm manifests, or release-only tooling files.
6. Install the generated ZIP on a clean WordPress 7.0+ site with the companion
`agents-api` plugin inactive, then confirm `AGENTS_API_PLUGIN_FILE` points to
- `openclawp/vendor/automattic/agents-api/agents-api.php`.
+ `openclawp/vendor/wordpress/agents-api/agents-api.php`.
7. Confirm `readme.txt` includes dependency, external-services, changelog, and
upgrade-notice sections.
diff --git a/includes/class-openclawp-bootstrap.php b/includes/class-openclawp-bootstrap.php
index 7e67b22..21444b6 100644
--- a/includes/class-openclawp-bootstrap.php
+++ b/includes/class-openclawp-bootstrap.php
@@ -206,7 +206,7 @@ public static function render_missing_dependencies_notice(): void {
$missing = array();
if ( ! defined( 'AGENTS_API_LOADED' ) ) {
- $missing[] = 'automattic/agents-api';
+ $missing[] = 'wordpress/agents-api';
}
if ( ! function_exists( 'wp_ai_client_prompt' ) ) {
$missing[] = 'WordPress 7.0+ (provides wp_ai_client_prompt())';
diff --git a/includes/class-openclawp-message-adapter.php b/includes/class-openclawp-message-adapter.php
index 7643d1c..02086c5 100644
--- a/includes/class-openclawp-message-adapter.php
+++ b/includes/class-openclawp-message-adapter.php
@@ -21,8 +21,12 @@ final class OpenclaWP_Message_Adapter {
* Convert a transcript array into a list of WP AI Client `Message` DTOs.
*
* Roles map: `user` → `MessageRoleEnum::user()`, `assistant`/`model` →
- * `MessageRoleEnum::model()`. System and tool messages are dropped — the
- * loop's tool-mediation path isn't wired through this adapter yet.
+ * `MessageRoleEnum::model()`. Tool-mediation turns round-trip as native
+ * function-call parts so the model sees its own calls and their results
+ * (without this, a tool-using turn re-issues the same call every turn
+ * because the result never reaches the model):
+ * - `tool_call` → model message carrying a `FunctionCall` part.
+ * - `tool_result` → user message carrying a `FunctionResponse` part.
* Returns the original transcript unchanged if WP AI Client is missing.
*
* @param array> $messages Transcript messages.
@@ -33,32 +37,92 @@ public static function to_ai_client_messages( array $messages ): array {
return $messages;
}
+ $user_role = \WordPress\AiClient\Messages\Enums\MessageRoleEnum::user();
+ $model_role = \WordPress\AiClient\Messages\Enums\MessageRoleEnum::model();
+
$out = array();
foreach ( $messages as $message ) {
+ $type = (string) ( $message['type'] ?? 'text' );
+
+ if ( 'tool_call' === $type ) {
+ $part = self::tool_call_part( $message );
+ if ( null !== $part ) {
+ $out[] = new \WordPress\AiClient\Messages\DTO\Message( $model_role, array( $part ) );
+ }
+ continue;
+ }
+
+ if ( 'tool_result' === $type ) {
+ $part = self::tool_result_part( $message );
+ if ( null !== $part ) {
+ $out[] = new \WordPress\AiClient\Messages\DTO\Message( $user_role, array( $part ) );
+ }
+ continue;
+ }
+
$role = (string) ( $message['role'] ?? '' );
$text = self::extract_text( $message['content'] ?? '' );
-
if ( '' === $text ) {
continue;
}
if ( 'user' === $role ) {
- $role_enum = \WordPress\AiClient\Messages\Enums\MessageRoleEnum::user();
+ $out[] = new \WordPress\AiClient\Messages\DTO\Message( $user_role, array( new \WordPress\AiClient\Messages\DTO\MessagePart( $text ) ) );
} elseif ( 'assistant' === $role || 'model' === $role ) {
- $role_enum = \WordPress\AiClient\Messages\Enums\MessageRoleEnum::model();
- } else {
- continue;
+ $out[] = new \WordPress\AiClient\Messages\DTO\Message( $model_role, array( new \WordPress\AiClient\Messages\DTO\MessagePart( $text ) ) );
}
-
- $out[] = new \WordPress\AiClient\Messages\DTO\Message(
- $role_enum,
- array( new \WordPress\AiClient\Messages\DTO\MessagePart( $text ) )
- );
}
return $out;
}
+ /**
+ * Build a `MessagePart` wrapping the `FunctionCall` for a stored `tool_call`
+ * transcript message. The function name is mapped back to the provider-safe
+ * form the model originally used (the stored name carries the `client/`
+ * loop prefix). Returns null when the DTO is unavailable or the name is empty.
+ *
+ * @param array $message Stored tool_call message.
+ * @return object|null
+ */
+ private static function tool_call_part( array $message ) {
+ if ( ! class_exists( '\\WordPress\\AiClient\\Tools\\DTO\\FunctionCall' ) ) {
+ return null;
+ }
+ $payload = is_array( $message['payload'] ?? null ) ? $message['payload'] : array();
+ $meta = is_array( $message['metadata'] ?? null ) ? $message['metadata'] : array();
+ $name = OpenclaWP_Tools_Resolver::provider_name( (string) ( $payload['tool_name'] ?? '' ) );
+ if ( '' === $name ) {
+ return null;
+ }
+ $id = (string) ( $meta['tool_call_id'] ?? '' );
+ $args = is_array( $payload['parameters'] ?? null ) ? $payload['parameters'] : array();
+
+ $call = new \WordPress\AiClient\Tools\DTO\FunctionCall( '' !== $id ? $id : null, $name, $args );
+ return new \WordPress\AiClient\Messages\DTO\MessagePart( $call );
+ }
+
+ /**
+ * Build a `MessagePart` wrapping the `FunctionResponse` for a stored
+ * `tool_result` transcript message, matched to its call by `tool_call_id`.
+ *
+ * @param array $message Stored tool_result message.
+ * @return object|null
+ */
+ private static function tool_result_part( array $message ) {
+ if ( ! class_exists( '\\WordPress\\AiClient\\Tools\\DTO\\FunctionResponse' ) ) {
+ return null;
+ }
+ $payload = is_array( $message['payload'] ?? null ) ? $message['payload'] : array();
+ $meta = is_array( $message['metadata'] ?? null ) ? $message['metadata'] : array();
+ $name = OpenclaWP_Tools_Resolver::provider_name( (string) ( $payload['tool_name'] ?? '' ) );
+ $id = (string) ( $meta['tool_call_id'] ?? '' );
+ $response = array_key_exists( 'result', $payload ) ? $payload['result'] : ( $message['content'] ?? '' );
+
+ $resp = new \WordPress\AiClient\Tools\DTO\FunctionResponse( '' !== $id ? $id : null, '' !== $name ? $name : null, $response );
+ return new \WordPress\AiClient\Messages\DTO\MessagePart( $resp );
+ }
+
/**
* Walk the transcript backwards and return the most recent assistant text.
*
diff --git a/includes/class-openclawp-runner.php b/includes/class-openclawp-runner.php
index 32c6efb..da08001 100644
--- a/includes/class-openclawp-runner.php
+++ b/includes/class-openclawp-runner.php
@@ -577,8 +577,12 @@ private static function extract_tool_calls( $result ): array {
continue;
}
+ // The model returns the provider-safe function name (no slash). Map it
+ // to the loop-facing `client/` form so the conversation loop's
+ // mediation matches it against the tool declarations and the executor's
+ // name map (both keyed on the loop name). {@see OpenclaWP_Tools_Resolver::loop_name()}.
$out[] = array(
- 'name' => $name,
+ 'name' => OpenclaWP_Tools_Resolver::loop_name( $name ),
'parameters' => is_array( $args ) ? $args : array(),
);
}
diff --git a/includes/class-openclawp-tool-discovery.php b/includes/class-openclawp-tool-discovery.php
index 509c65b..b780ac8 100644
--- a/includes/class-openclawp-tool-discovery.php
+++ b/includes/class-openclawp-tool-discovery.php
@@ -190,22 +190,24 @@ public static function execute_tool( array $args ) {
public static function meta_tool_resolver_payload( array $catalog_tools = array() ): array {
$list_name = OpenclaWP_Tools_Resolver::sanitize_name( self::LIST_ABILITY );
$execute_name = OpenclaWP_Tools_Resolver::sanitize_name( self::EXECUTE_ABILITY );
+ $list_loop = OpenclaWP_Tools_Resolver::loop_name( $list_name );
+ $execute_loop = OpenclaWP_Tools_Resolver::loop_name( $execute_name );
$list_params = self::list_tools_input_schema();
$execute_params = self::execute_tool_input_schema();
$declarations = array(
- $list_name => array(
- 'name' => $list_name,
- 'source' => 'openclawp',
+ $list_loop => array(
+ 'name' => $list_loop,
+ 'source' => OpenclaWP_Tools_Resolver::TOOL_SOURCE,
'description' => self::list_tools_description(),
'parameters' => $list_params,
'executor' => 'client',
'scope' => 'run',
),
- $execute_name => array(
- 'name' => $execute_name,
- 'source' => 'openclawp',
+ $execute_loop => array(
+ 'name' => $execute_loop,
+ 'source' => OpenclaWP_Tools_Resolver::TOOL_SOURCE,
'description' => self::execute_tool_description(),
'parameters' => $execute_params,
'executor' => 'client',
@@ -231,8 +233,8 @@ public static function meta_tool_resolver_payload( array $catalog_tools = array(
'declarations' => $declarations,
'declarations_for_provider' => $provider_decls,
'name_to_ability' => array(
- $list_name => self::LIST_ABILITY,
- $execute_name => self::EXECUTE_ABILITY,
+ $list_loop => self::LIST_ABILITY,
+ $execute_loop => self::EXECUTE_ABILITY,
),
'catalog_tools' => array_values( array_filter( array_map( 'strval', $catalog_tools ) ) ),
);
diff --git a/includes/class-openclawp-tools-resolver.php b/includes/class-openclawp-tools-resolver.php
index 3d9af82..1e5c57a 100644
--- a/includes/class-openclawp-tools-resolver.php
+++ b/includes/class-openclawp-tools-resolver.php
@@ -30,6 +30,44 @@
final class OpenclaWP_Tools_Resolver {
+ /**
+ * Source segment for client tool declarations. The agents-api conversation
+ * loop validates client tool names against `^client/[a-z][a-z0-9_-]*$` and
+ * derives the declaration's source from the segment before the slash, which
+ * MUST equal `client` (see WP_Agent_Tool_Declaration::validate()). Provider
+ * APIs reject `/` in function names, so the name the model sees stays the
+ * sanitized `__` form; the loop-facing declaration + executor key it as
+ * `client/`. {@see self::loop_name()}.
+ */
+ public const TOOL_SOURCE = 'client';
+
+ /**
+ * Map a provider-safe declaration name (what the model sees, e.g.
+ * `openclawp__get-recent-posts`) to the loop-facing name the agents-api
+ * conversation loop validates and matches tool calls against
+ * (`client/openclawp__get-recent-posts`). Idempotent.
+ */
+ public static function loop_name( string $declared_name ): string {
+ $prefix = self::TOOL_SOURCE . '/';
+ if ( 0 === strpos( $declared_name, $prefix ) ) {
+ return $declared_name;
+ }
+ return $prefix . $declared_name;
+ }
+
+ /**
+ * Inverse of {@see self::loop_name()}: strip the `client/` prefix off a
+ * loop-facing tool name to recover the provider-safe name the model used
+ * (and expects back in FunctionCall / FunctionResponse parts). Idempotent.
+ */
+ public static function provider_name( string $loop_name ): string {
+ $prefix = self::TOOL_SOURCE . '/';
+ if ( 0 === strpos( $loop_name, $prefix ) ) {
+ return substr( $loop_name, strlen( $prefix ) );
+ }
+ return $loop_name;
+ }
+
/**
* @return array{
* declarations: array,
@@ -82,13 +120,14 @@ public static function for_agent( WP_Agent $agent ): array {
}
$declared_name = self::sanitize_name( $ability_name );
+ $loop_name = self::loop_name( $declared_name );
$description = method_exists( $ability, 'get_description' ) ? (string) $ability->get_description() : '';
$input_schema = method_exists( $ability, 'get_input_schema' ) ? $ability->get_input_schema() : null;
$parameters = is_array( $input_schema ) ? $input_schema : array( 'type' => 'object', 'properties' => array() );
- $declarations[ $declared_name ] = array(
- 'name' => $declared_name,
- 'source' => 'openclawp',
+ $declarations[ $loop_name ] = array(
+ 'name' => $loop_name,
+ 'source' => self::TOOL_SOURCE,
'description' => $description,
'parameters' => $parameters,
'executor' => 'client',
@@ -101,7 +140,7 @@ public static function for_agent( WP_Agent $agent ): array {
$parameters
);
- $name_to_abil[ $declared_name ] = $ability_name;
+ $name_to_abil[ $loop_name ] = $ability_name;
}
}
@@ -130,6 +169,7 @@ public static function for_agent( WP_Agent $agent ): array {
}
$declared_name = self::sanitize_name( 'delegate-to-' . $subagent_slug );
+ $loop_name = self::loop_name( $declared_name );
$label = method_exists( $subagent, 'get_label' ) ? (string) $subagent->get_label() : $subagent_slug;
$bio = method_exists( $subagent, 'get_description' ) ? (string) $subagent->get_description() : '';
$description = sprintf(
@@ -138,9 +178,9 @@ public static function for_agent( WP_Agent $agent ): array {
'' === $bio ? '(no description provided)' : $bio
);
- $declarations[ $declared_name ] = array(
- 'name' => $declared_name,
- 'source' => 'openclawp',
+ $declarations[ $loop_name ] = array(
+ 'name' => $loop_name,
+ 'source' => self::TOOL_SOURCE,
'description' => $description,
'parameters' => $delegate_parameters,
'executor' => 'client',
@@ -153,7 +193,7 @@ public static function for_agent( WP_Agent $agent ): array {
$delegate_parameters
);
- $delegate_targets[ $declared_name ] = $subagent_slug;
+ $delegate_targets[ $loop_name ] = $subagent_slug;
}
}
diff --git a/openclawp.php b/openclawp.php
index 12d850c..5b006d3 100644
--- a/openclawp.php
+++ b/openclawp.php
@@ -32,7 +32,7 @@
}
if ( ! defined( 'AGENTS_API_LOADED' ) ) {
- $openclawp_agents_api_bootstrap = __DIR__ . '/vendor/automattic/agents-api/agents-api.php';
+ $openclawp_agents_api_bootstrap = __DIR__ . '/vendor/wordpress/agents-api/agents-api.php';
if ( file_exists( $openclawp_agents_api_bootstrap ) ) {
require_once $openclawp_agents_api_bootstrap;
}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 6623276..1d4ab1c 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -189,7 +189,7 @@ function esc_url_raw( string $url ): string {
require_once __DIR__ . '/../vendor/autoload.php';
// Stub the agents-api channel base class. The real one lives in
-// `automattic/agents-api`, which is a dev-main composer dep that isn't
+// `wordpress/agents-api`, which is a dev-main composer dep that isn't
// always installed during PHPUnit runs (CI environments, fresh checkouts).
// Tests in tests/unit/ exercise pure-PHP helpers on the subclasses; the
// actual loop logic is covered by tests/smoke.php inside a real WP.
diff --git a/tests/integration/prompt-assembly/__snapshots__/coordinator--chat.json b/tests/integration/prompt-assembly/__snapshots__/coordinator--chat.json
index 3d3c7b3..06a2572 100644
--- a/tests/integration/prompt-assembly/__snapshots__/coordinator--chat.json
+++ b/tests/integration/prompt-assembly/__snapshots__/coordinator--chat.json
@@ -2,8 +2,8 @@
"agent_slug": "openclawp-coordinator",
"channel": "chat",
"delegate_targets": {
- "delegate-to-openclawp-loop-demo": "openclawp-loop-demo",
- "delegate-to-openclawp-site-introspection": "openclawp-site-introspection"
+ "client/delegate-to-openclawp-loop-demo": "openclawp-loop-demo",
+ "client/delegate-to-openclawp-site-introspection": "openclawp-site-introspection"
},
"max_turns": 6,
"messages": [
@@ -26,7 +26,7 @@
"tool_catalog": [
{
"description": "Delegate to subagent openclaWP Site Introspection. Use when the request is in this subagent's scope. Subagent description: You are a helpful assistant that answers questions about this WordPress site. You have read-only access to four tools: openclawp__get-recent-posts (recent published posts), openclawp__count-comments (comment moderation totals), openclawp__get-active-plugins (currently active plugins), and openclawp__get-current-user (the human you are talking to). Always call the relevant tool before answering a factual question — never guess. Quote tool output values directly. Be concise.",
- "name": "delegate-to-openclawp-site-introspection",
+ "name": "client/delegate-to-openclawp-site-introspection",
"parameters": {
"properties": {
"prompt": {
@@ -39,11 +39,11 @@
],
"type": "object"
},
- "source": "openclawp"
+ "source": "client"
},
{
"description": "Delegate to subagent openclaWP Loop Demo. Use when the request is in this subagent's scope. Subagent description: You are a precise assistant. You have access to one tool: openclawp__get-time, which returns the current time. When the user asks for the time, the current date, or anything time-related, you MUST call openclawp__get-time first and use its result in your reply. Never guess the time.",
- "name": "delegate-to-openclawp-loop-demo",
+ "name": "client/delegate-to-openclawp-loop-demo",
"parameters": {
"properties": {
"prompt": {
@@ -56,7 +56,7 @@
],
"type": "object"
},
- "source": "openclawp"
+ "source": "client"
}
]
}
diff --git a/tests/integration/prompt-assembly/__snapshots__/loop-demo--chat.json b/tests/integration/prompt-assembly/__snapshots__/loop-demo--chat.json
index 5e6386e..ccd690a 100644
--- a/tests/integration/prompt-assembly/__snapshots__/loop-demo--chat.json
+++ b/tests/integration/prompt-assembly/__snapshots__/loop-demo--chat.json
@@ -23,11 +23,11 @@
"tool_catalog": [
{
"description": "Returns the current server time in ISO 8601 (UTC). Call this whenever the user asks for the time, the current date, or how long ago something happened.",
- "name": "openclawp__get-time",
+ "name": "client/openclawp__get-time",
"parameters": {
"type": "object"
},
- "source": "openclawp"
+ "source": "client"
}
]
}
diff --git a/tests/integration/prompt-assembly/__snapshots__/site-introspection--whatsapp.json b/tests/integration/prompt-assembly/__snapshots__/site-introspection--whatsapp.json
index 238598b..e6ae0cc 100644
--- a/tests/integration/prompt-assembly/__snapshots__/site-introspection--whatsapp.json
+++ b/tests/integration/prompt-assembly/__snapshots__/site-introspection--whatsapp.json
@@ -28,7 +28,7 @@
"tool_catalog": [
{
"description": "Returns up to 5 most recent published posts (title + permalink + excerpt).",
- "name": "openclawp__get-recent-posts",
+ "name": "client/openclawp__get-recent-posts",
"parameters": {
"properties": {
"limit": {
@@ -39,31 +39,31 @@
},
"type": "object"
},
- "source": "openclawp"
+ "source": "client"
},
{
"description": "Returns counts of comments by status (approved, pending, spam, trash).",
- "name": "openclawp__count-comments",
+ "name": "client/openclawp__count-comments",
"parameters": {
"type": "object"
},
- "source": "openclawp"
+ "source": "client"
},
{
"description": "Lists currently active plugins on this site (name + version).",
- "name": "openclawp__get-active-plugins",
+ "name": "client/openclawp__get-active-plugins",
"parameters": {
"type": "object"
},
- "source": "openclawp"
+ "source": "client"
},
{
"description": "Returns the logged-in WordPress user (id, login, display name, roles).",
- "name": "openclawp__get-current-user",
+ "name": "client/openclawp__get-current-user",
"parameters": {
"type": "object"
},
- "source": "openclawp"
+ "source": "client"
}
]
}
diff --git a/tests/integration/prompt-assembly/bootstrap.php b/tests/integration/prompt-assembly/bootstrap.php
index 9c80132..318c712 100644
--- a/tests/integration/prompt-assembly/bootstrap.php
+++ b/tests/integration/prompt-assembly/bootstrap.php
@@ -23,7 +23,8 @@
// directly (rather than via the substrate bootstrap) because the substrate
// also tries to register WordPress actions, REST routes, and hooks that
// aren't available in this minimal PHPUnit environment.
-require_once dirname( __DIR__, 3 ) . '/vendor/automattic/agents-api/src/Registry/class-wp-agent.php';
+require_once dirname( __DIR__, 3 ) . '/vendor/wordpress/agents-api/src/Registry/class-wp-agent-runtime-overrides.php';
+require_once dirname( __DIR__, 3 ) . '/vendor/wordpress/agents-api/src/Registry/class-wp-agent.php';
// Stub the WP AI Client FunctionDeclaration DTO. The real class ships with
// `wordpress/php-ai-client` (or WP 7.0 core) and is only used at runtime to
diff --git a/tests/phpunit-prepend.php b/tests/phpunit-prepend.php
new file mode 100644
index 0000000..f115e1a
--- /dev/null
+++ b/tests/phpunit-prepend.php
@@ -0,0 +1,20 @@
+assertSame( $input, $output );
}
+ public function test_to_ai_client_messages_round_trips_tool_mediation_parts(): void {
+ self::install_ai_client_stubs();
+
+ $output = OpenclaWP_Message_Adapter::to_ai_client_messages(
+ array(
+ array( 'role' => 'user', 'content' => 'Run the echo tool.' ),
+ array(
+ 'type' => 'tool_call',
+ 'payload' => array(
+ 'tool_name' => 'client/openclawp__echo',
+ 'parameters' => array( 'text' => 'hello' ),
+ ),
+ 'metadata' => array( 'tool_call_id' => 'call-1' ),
+ ),
+ array(
+ 'type' => 'tool_result',
+ 'payload' => array(
+ 'tool_name' => 'client/openclawp__echo',
+ 'result' => array( 'ok' => true ),
+ ),
+ 'metadata' => array( 'tool_call_id' => 'call-1' ),
+ ),
+ )
+ );
+
+ $this->assertCount( 3, $output );
+
+ $call = $output[1]->getParts()[0]->getContent();
+ $this->assertInstanceOf( \WordPress\AiClient\Tools\DTO\FunctionCall::class, $call );
+ $this->assertSame( 'call-1', $call->getId() );
+ $this->assertSame( 'openclawp__echo', $call->getName() );
+ $this->assertSame( array( 'text' => 'hello' ), $call->getArgs() );
+
+ $response = $output[2]->getParts()[0]->getContent();
+ $this->assertInstanceOf( \WordPress\AiClient\Tools\DTO\FunctionResponse::class, $response );
+ $this->assertSame( 'call-1', $response->getId() );
+ $this->assertSame( 'openclawp__echo', $response->getName() );
+ $this->assertSame( array( 'ok' => true ), $response->getResponse() );
+ }
+
public function test_last_assistant_text_returns_most_recent_assistant_message(): void {
$messages = array(
array( 'role' => 'user', 'content' => 'q1' ),
@@ -79,4 +119,129 @@ public function test_last_assistant_text_handles_string_content(): void {
$this->assertSame( 'plain string reply', OpenclaWP_Message_Adapter::last_assistant_text( $messages ) );
}
+
+ private static function install_ai_client_stubs(): void {
+ $aliases = array(
+ AiClientStubs\Message::class => 'WordPress\\AiClient\\Messages\\DTO\\Message',
+ AiClientStubs\MessagePart::class => 'WordPress\\AiClient\\Messages\\DTO\\MessagePart',
+ AiClientStubs\MessageRoleEnum::class => 'WordPress\\AiClient\\Messages\\Enums\\MessageRoleEnum',
+ AiClientStubs\FunctionCall::class => 'WordPress\\AiClient\\Tools\\DTO\\FunctionCall',
+ AiClientStubs\FunctionResponse::class => 'WordPress\\AiClient\\Tools\\DTO\\FunctionResponse',
+ );
+
+ foreach ( $aliases as $source => $alias ) {
+ if ( ! class_exists( '\\' . $alias, false ) ) {
+ class_alias( $source, $alias );
+ }
+ }
+ }
+}
+
+namespace OpenclaWP\Tests\Unit\AiClientStubs;
+
+final class MessageRoleEnum {
+ private string $value;
+
+ private function __construct( string $value ) {
+ $this->value = $value;
+ }
+
+ public static function user(): self {
+ return new self( 'user' );
+ }
+
+ public static function model(): self {
+ return new self( 'model' );
+ }
+
+ public function value(): string {
+ return $this->value;
+ }
+}
+
+final class Message {
+ private MessageRoleEnum $role;
+ /** @var array */
+ private array $parts;
+
+ /**
+ * @param array $parts
+ */
+ public function __construct( MessageRoleEnum $role, array $parts ) {
+ $this->role = $role;
+ $this->parts = $parts;
+ }
+
+ public function getRole(): MessageRoleEnum {
+ return $this->role;
+ }
+
+ /**
+ * @return array
+ */
+ public function getParts(): array {
+ return $this->parts;
+ }
+}
+
+final class MessagePart {
+ /** @var mixed */
+ private $content;
+
+ public function __construct( $content ) {
+ $this->content = $content;
+ }
+
+ public function getContent() {
+ return $this->content;
+ }
+}
+
+final class FunctionCall {
+ private ?string $id;
+ private string $name;
+ private array $args;
+
+ public function __construct( ?string $id, string $name, array $args ) {
+ $this->id = $id;
+ $this->name = $name;
+ $this->args = $args;
+ }
+
+ public function getId(): ?string {
+ return $this->id;
+ }
+
+ public function getName(): string {
+ return $this->name;
+ }
+
+ public function getArgs(): array {
+ return $this->args;
+ }
+}
+
+final class FunctionResponse {
+ private ?string $id;
+ private ?string $name;
+ /** @var mixed */
+ private $response;
+
+ public function __construct( ?string $id, ?string $name, $response ) {
+ $this->id = $id;
+ $this->name = $name;
+ $this->response = $response;
+ }
+
+ public function getId(): ?string {
+ return $this->id;
+ }
+
+ public function getName(): ?string {
+ return $this->name;
+ }
+
+ public function getResponse() {
+ return $this->response;
+ }
}