From b204da06e6731f99f89979fc45d006b87a2b1dd8 Mon Sep 17 00:00:00 2001 From: Aaron Ware Date: Fri, 21 Aug 2026 11:27:43 -0400 Subject: [PATCH] fix: emit provider-safe host tool names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host tool declarations reach the provider under their canonical namespaced name, and provider tool-name validation rejects the `/`. This is the same constraint #320 addressed for client runtime declarations, on the half that #320 did not cover. Client runtime tools could sidestep it, because the consumer chooses the name and can simply declare `filesystem_write`. Host tools cannot: they are derived from abilities, and the Abilities API requires a namespaced `namespace/name`, refusing to register anything else. So every ability-backed declaration carries a `/`, and `WP_Agent_Default_Provider_Turn_Adapter` emits it verbatim. Observed against a live OpenAI-compatible endpoint, using the shipped default chat handler with an ability-backed toolset: tools.0.custom.name: String should match pattern '^[a-zA-Z0-9_-]{1,128}$' The alias this needs is already computed and already carried: `normalizeForConversationRequest()` records `provider_safe_name` on any declaration whose canonical name is not provider-safe, and `WP_Agent_Provider_Turn_Request` normalizes declarations on construction, so it is present by the time the adapter maps them. The return leg is already wired too — `WP_Agent_Tool_Execution_Core` resolves the provider's emitted name back through `canonicalNameForProviderToolName()`, so mediation still matches the canonical declaration and tool observability still reports the canonical name. Only the outbound mapping was missing. Three existing assertions changed expectation rather than being added to, and they are the ones worth reviewing: they asserted that the canonical namespaced name is what reaches the provider, which is the behaviour being corrected. The suite could not have caught this otherwise, because every provider result in it is stubbed, so no test sends a tool name to anything that validates it. Co-Authored-By: Claude Opus 5 (1M context) --- ...wp-agent-default-provider-turn-adapter.php | 23 +++++++++++- tests/default-provider-turn-adapter-smoke.php | 37 ++++++++++++++++--- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/Runtime/class-wp-agent-default-provider-turn-adapter.php b/src/Runtime/class-wp-agent-default-provider-turn-adapter.php index 4e75995..53e691c 100644 --- a/src/Runtime/class-wp-agent-default-provider-turn-adapter.php +++ b/src/Runtime/class-wp-agent-default-provider-turn-adapter.php @@ -1194,11 +1194,30 @@ private static function function_declarations( array $tool_declarations ): array $declarations = array(); foreach ( $tool_declarations as $name => $tool ) { - $tool_name = is_string( $tool['name'] ?? null ) && '' !== $tool['name'] ? $tool['name'] : (string) $name; - if ( '' === $tool_name ) { + $canonical_name = is_string( $tool['name'] ?? null ) && '' !== $tool['name'] ? $tool['name'] : (string) $name; + if ( '' === $canonical_name ) { continue; } + /* + * Send the provider-safe alias, not the canonical name. + * + * Canonical tool names are namespaced (`namespace/tool`), and provider + * tool-name validation rejects the slash — the same constraint #320 + * addressed for client runtime tool declarations. Host tools derived + * from abilities cannot avoid it: the Abilities API requires a + * namespaced name, so every ability-backed declaration carries one. + * + * `normalizeForConversationRequest()` already records the alias on any + * declaration that needs one, and WP_Agent_Tool_Execution_Core maps the + * provider's emitted name back through + * `canonicalNameForProviderToolName()`, so mediation still resolves the + * canonical declaration. + */ + $tool_name = is_string( $tool['provider_safe_name'] ?? null ) && '' !== $tool['provider_safe_name'] + ? $tool['provider_safe_name'] + : $canonical_name; + $description = is_string( $tool['description'] ?? null ) ? $tool['description'] : ''; $parameters = is_array( $tool['parameters'] ?? null ) ? $tool['parameters'] : array(); diff --git a/tests/default-provider-turn-adapter-smoke.php b/tests/default-provider-turn-adapter-smoke.php index d1635ef..25843e8 100644 --- a/tests/default-provider-turn-adapter-smoke.php +++ b/tests/default-provider-turn-adapter-smoke.php @@ -399,7 +399,7 @@ public function get_error_data() { agents_api_smoke_assert_equals( 1, count( $GLOBALS['__adapter_smoke']['prompt_parts'] ?? array() ), 'run_turn sends the latest user turn as the current prompt', $failures, $passes ); agents_api_smoke_assert_equals( 2, count( $GLOBALS['__adapter_smoke']['history'] ?? array() ), 'run_turn sends earlier turns as history', $failures, $passes ); agents_api_smoke_assert_equals( 1, count( $GLOBALS['__adapter_smoke']['declarations'] ?? array() ), 'run_turn maps tool declarations to function declarations', $failures, $passes ); - agents_api_smoke_assert_equals( 'client/lookup', $GLOBALS['__adapter_smoke']['declarations'][0]->name ?? '', 'function declaration carries the logical tool name', $failures, $passes ); + agents_api_smoke_assert_equals( 'client__lookup', $GLOBALS['__adapter_smoke']['declarations'][0]->name ?? '', 'function declaration carries the provider-safe tool name', $failures, $passes ); $model_parameters = $GLOBALS['__adapter_smoke']['declarations'][0]->parameters ?? array(); agents_api_smoke_assert_equals( false, array_key_exists( 'scope_id', $model_parameters['properties'] ?? array() ), 'function declaration excludes authoritative parameters from model input', $failures, $passes ); agents_api_smoke_assert_equals( array( 'query' ), $model_parameters['required'] ?? array(), 'function declaration excludes authoritative parameters from model requirements', $failures, $passes ); @@ -865,7 +865,7 @@ static function ( array $payload ) use ( &$dispatch_payload, $dispatcher_result agents_api_smoke_assert_equals( 1, count( $dispatch_payload['prompt_parts'] ?? array() ), 'dispatch payload carries the current-prompt message parts', $failures, $passes ); agents_api_smoke_assert_equals( 2, count( $dispatch_payload['history'] ?? array() ), 'dispatch payload carries the history messages', $failures, $passes ); agents_api_smoke_assert_equals( 1, count( $dispatch_payload['function_declarations'] ?? array() ), 'dispatch payload carries the function declarations', $failures, $passes ); - agents_api_smoke_assert_equals( 'client/lookup', $dispatch_payload['function_declarations'][0]->name ?? '', 'dispatch payload function declaration carries the logical tool name', $failures, $passes ); + agents_api_smoke_assert_equals( 'client__lookup', $dispatch_payload['function_declarations'][0]->name ?? '', 'dispatch payload function declaration carries the provider-safe tool name', $failures, $passes ); agents_api_smoke_assert_equals( 0.5, $dispatch_payload['options']['temperature'] ?? null, 'dispatch payload carries adapter options (temperature)', $failures, $passes ); agents_api_smoke_assert_equals( 256, $dispatch_payload['options']['max_tokens'] ?? null, 'dispatch payload carries adapter options (max_tokens)', $failures, $passes ); agents_api_smoke_assert_equals( true, ( $dispatch_payload['request'] ?? null ) instanceof AgentsAPI\AI\WP_Agent_Provider_Turn_Request, 'dispatch payload carries the WP_Agent_Provider_Turn_Request', $failures, $passes ); @@ -1143,11 +1143,38 @@ static function ( array $payload ) use ( &$explicit_calls, $make_result ) { agents_api_smoke_assert_equals( true, false !== strpos( $wrapped_empty, '"parameters":{' ), 'wrapped tool payload contains "parameters":{ (an object)', $failures, $passes ); agents_api_smoke_assert_equals( false, false !== strpos( $wrapped_empty, '"parameters":[' ), 'wrapped tool payload never contains "parameters":[ (the OpenAI 400 shape)', $failures, $passes ); - // The real-schema tool: object stays an object and the declared schema is preserved verbatim. - $real_json = json_encode( $decls_by_name['client/lookup']->parameters ?? null ); + // The real-schema tool: object stays an object and the declared schema is preserved + // verbatim. Keyed by the provider-safe alias, because that is the name the + // declaration is emitted under (see the provider-safe assertions below). + $real_json = json_encode( $decls_by_name['client__lookup']->parameters ?? null ); agents_api_smoke_assert_equals( true, '{' === substr( $real_json, 0, 1 ), 'real-schema tool parameters remain a JSON object', $failures, $passes ); agents_api_smoke_assert_equals( true, false !== strpos( $real_json, '"query"' ), 'real-schema tool preserves its declared properties unchanged', $failures, $passes ); - agents_api_smoke_assert_equals( 'object', $decls_by_name['client/lookup']->parameters['type'] ?? '', 'real-schema tool keeps its declared type', $failures, $passes ); + agents_api_smoke_assert_equals( 'object', $decls_by_name['client__lookup']->parameters['type'] ?? '', 'real-schema tool keeps its declared type', $failures, $passes ); + + /* + * Provider-safe tool names. + * + * Provider tool-name validation rejects the `/` in a canonical namespaced + * name — the same constraint #320 addressed for client runtime declarations. + * Host tools derived from abilities cannot dodge it, because the Abilities API + * requires a namespaced name, so the alias has to be what reaches the provider. + * Mediation still resolves the canonical declaration, because + * WP_Agent_Tool_Execution_Core maps the emitted name back through + * canonicalNameForProviderToolName(). + */ + agents_api_smoke_assert_equals( true, isset( $decls_by_name['client__lookup'] ), 'namespaced tool is emitted under its provider-safe alias', $failures, $passes ); + agents_api_smoke_assert_equals( false, isset( $decls_by_name['client/lookup'] ), 'namespaced tool is never emitted under its canonical name', $failures, $passes ); + agents_api_smoke_assert_equals( true, isset( $decls_by_name['workspace_show'] ), 'an already provider-safe name is emitted unchanged', $failures, $passes ); + + foreach ( array_keys( $decls_by_name ) as $emitted_name ) { + agents_api_smoke_assert_equals( + 1, + preg_match( '/^[a-zA-Z0-9_-]{1,128}$/', (string) $emitted_name ), + sprintf( 'emitted tool name "%s" satisfies provider tool-name validation', $emitted_name ), + $failures, + $passes + ); + } agents_api_smoke_finish( 'Agents API default provider-turn adapter', $failures, $passes ); }