diff --git a/src/Runtime/class-wp-agent-provider-turn-request.php b/src/Runtime/class-wp-agent-provider-turn-request.php index b28f18b..bdebfaf 100644 --- a/src/Runtime/class-wp-agent-provider-turn-request.php +++ b/src/Runtime/class-wp-agent-provider-turn-request.php @@ -8,6 +8,7 @@ namespace AgentsAPI\AI; use AgentsAPI\AI\Tools\WP_Agent_Tool_Declaration; +use AgentsAPI\AI\Tools\WP_Agent_Tool_Parameters; defined( 'ABSPATH' ) || exit; @@ -160,6 +161,8 @@ private static function normalize_tool_declarations( array $tool_declarations ): throw self::invalid( 'tool_declarations.' . (string) $key . '.name', 'must be a non-empty string' ); } + $tool['parameters'] = WP_Agent_Tool_Parameters::modelParameterSchema( $tool ); + $normalized[ $name ] = $tool; } diff --git a/src/Tools/class-wp-agent-tool-parameters.php b/src/Tools/class-wp-agent-tool-parameters.php index 9460113..7eae422 100644 --- a/src/Tools/class-wp-agent-tool-parameters.php +++ b/src/Tools/class-wp-agent-tool-parameters.php @@ -32,11 +32,11 @@ class WP_Agent_Tool_Parameters { /** * Merge declared client-context bindings with runtime tool parameters. * - * Caller-supplied parameters always win. Values from `$context` are only - * pulled in for parameter slots that the tool declaration explicitly opts - * into via `client_context_bindings`. This keeps sensitive parameters - * auditable: a context key can never silently satisfy a required tool - * argument the tool author didn't expect to come from context. + * Caller-supplied parameters win unless a binding declares + * `authoritative => true`. Values from `$context` are only pulled in for + * parameter slots that the tool declaration explicitly opts into. This keeps + * sensitive parameters auditable: a context key can never silently satisfy a + * required tool argument the tool author didn't expect to come from context. * * Two declaration shapes are accepted: * @@ -45,8 +45,12 @@ class WP_Agent_Tool_Parameters { * - Flat list: `[ 'sender_id', 'connector_id' ]` — same-name * binding, equivalent to `[ 'sender_id' => 'sender_id', ... ]`. * - * Empty / null context values are ignored so they don't silently - * satisfy a required-parameter check. + * Empty / null ordinary context values are ignored so they don't silently + * satisfy a required-parameter check. Authoritative bindings preserve any + * explicitly present value, including null, and reject caller substitutes + * when their context path is absent. Their precedence is resolved context, + * then binding-local default, then top-level parameter default; caller input + * never participates in that chain. * * @param array $tool_parameters Runtime tool-call parameters. * @param array $context Host runtime context for this invocation. @@ -54,13 +58,19 @@ class WP_Agent_Tool_Parameters { * @return array Complete parameters for execution. */ public static function buildParameters( array $tool_parameters, array $context = array(), array $tool_definition = array() ): array { + $defaults = self::parameterDefaults( $tool_definition ); $parameters = array(); - foreach ( self::parameterDefaults( $tool_definition ) as $parameter_name => $value ) { + foreach ( $defaults as $parameter_name => $value ) { $parameters[ $parameter_name ] = $value; } - foreach ( self::parameterBindings( $tool_definition ) as $parameter_name => $binding ) { + $bindings = self::parameterBindings( $tool_definition ); + foreach ( $bindings as $parameter_name => $binding ) { + if ( ! empty( $binding['authoritative'] ) ) { + continue; + } + $resolved = self::resolveBindingValue( $binding, $context ); if ( ! $resolved['found'] ) { if ( array_key_exists( 'default', $binding ) ) { @@ -79,6 +89,23 @@ public static function buildParameters( array $tool_parameters, array $context = $parameters[ $key ] = $value; } + foreach ( $bindings as $parameter_name => $binding ) { + if ( empty( $binding['authoritative'] ) ) { + continue; + } + + $resolved = self::resolveBindingValue( $binding, $context ); + if ( $resolved['found'] ) { + $parameters[ $parameter_name ] = $resolved['value']; + } elseif ( array_key_exists( 'default', $binding ) ) { + $parameters[ $parameter_name ] = $binding['default']; + } elseif ( array_key_exists( $parameter_name, $defaults ) ) { + $parameters[ $parameter_name ] = $defaults[ $parameter_name ]; + } else { + unset( $parameters[ $parameter_name ] ); + } + } + return $parameters; } @@ -86,7 +113,7 @@ public static function buildParameters( array $tool_parameters, array $context = * Normalize explicit parameter bindings, including legacy client-context bindings. * * @param array $tool_definition Normalized tool declaration. - * @return array + * @return array */ public static function normalizeParameterBindings( array $tool_definition ): array { $normalized = array(); @@ -171,7 +198,7 @@ public static function normalizeParameterDefaults( array $tool_definition ): arr * Normalize parameter bindings from a declaration that has already been validated. * * @param array $tool_definition Normalized tool declaration. - * @return array + * @return array */ private static function parameterBindings( array $tool_definition ): array { try { @@ -201,7 +228,7 @@ private static function parameterDefaults( array $tool_definition ): array { * Normalize one parameter binding declaration. * * @param mixed $binding Raw binding declaration. - * @return array{source: string, path: string, sensitive?: bool, default?: mixed} + * @return array{source: string, path: string, sensitive?: bool, default?: mixed, authoritative?: bool} */ private static function normalizeParameterBinding( $binding ): array { if ( is_string( $binding ) ) { @@ -212,6 +239,11 @@ private static function normalizeParameterBinding( $binding ): array { throw new \InvalidArgumentException( 'invalid_parameter_bindings: parameter_bindings' ); } + $unknown_fields = array_diff( array_keys( $binding ), array( 'source', 'path', 'sensitive', 'default', 'authoritative' ) ); + if ( ! empty( $unknown_fields ) ) { + throw new \InvalidArgumentException( 'invalid_parameter_bindings: parameter_bindings' ); + } + $source = $binding['source'] ?? ''; $path = $binding['path'] ?? ''; if ( ! is_string( $source ) || ! is_string( $path ) || '' === trim( $path ) || ! isset( self::ALLOWED_CONTEXT_SOURCES[ $source ] ) ) { @@ -239,9 +271,91 @@ private static function normalizeParameterBinding( $binding ): array { $normalized['default'] = $binding['default']; } + if ( array_key_exists( 'authoritative', $binding ) ) { + if ( ! is_bool( $binding['authoritative'] ) ) { + throw new \InvalidArgumentException( 'invalid_parameter_bindings: parameter_bindings' ); + } + if ( $binding['authoritative'] ) { + $normalized['authoritative'] = true; + } + } + return $normalized; } + /** + * Return the parameter schema that may be exposed to a model. + * + * Authoritative parameters are host-controlled execution inputs, so they are + * removed from model-visible properties and required lists, including schema + * branches composed through `allOf`, `anyOf`, or `oneOf`. + * + * @param array $tool_definition Normalized tool declaration. + * @return array Model-visible parameter schema. + */ + public static function modelParameterSchema( array $tool_definition ): array { + $schema = isset( $tool_definition['parameters'] ) && is_array( $tool_definition['parameters'] ) + ? $tool_definition['parameters'] + : array(); + + $authoritative = array(); + foreach ( self::parameterBindings( $tool_definition ) as $parameter_name => $binding ) { + if ( ! empty( $binding['authoritative'] ) ) { + $authoritative[ $parameter_name ] = true; + } + } + + if ( empty( $authoritative ) ) { + return $schema; + } + + return self::removeAuthoritativeParametersFromSchema( $schema, $authoritative ); + } + + /** + * Remove authoritative inputs from one schema node and its composition branches. + * + * @param array $schema Parameter schema node. + * @param array $authoritative Authoritative parameter names. + * @return array Sanitized schema node. + */ + private static function removeAuthoritativeParametersFromSchema( array $schema, array $authoritative ): array { + if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) { + foreach ( $authoritative as $parameter_name => $_ ) { + unset( $schema['properties'][ $parameter_name ] ); + } + } else { + foreach ( $authoritative as $parameter_name => $_ ) { + if ( isset( $schema[ $parameter_name ] ) && is_array( $schema[ $parameter_name ] ) ) { + unset( $schema[ $parameter_name ] ); + } + } + } + + if ( isset( $schema['required'] ) && is_array( $schema['required'] ) ) { + $schema['required'] = array_values( + array_filter( + $schema['required'], + static fn( $parameter_name ): bool => ! is_string( $parameter_name ) || ! isset( $authoritative[ $parameter_name ] ) + ) + ); + } + + foreach ( array( 'allOf', 'anyOf', 'oneOf' ) as $composition ) { + if ( ! isset( $schema[ $composition ] ) || ! is_array( $schema[ $composition ] ) ) { + continue; + } + + foreach ( $schema[ $composition ] as $index => $branch ) { + if ( is_array( $branch ) ) { + $schema[ $composition ][ $index ] = self::removeAuthoritativeParametersFromSchema( $branch, $authoritative ); + } + } + } + + return $schema; + } + /** * Normalize the compact `source.dot.path` binding form. * diff --git a/tests/default-provider-turn-adapter-smoke.php b/tests/default-provider-turn-adapter-smoke.php index e91da36..531e7e3 100644 --- a/tests/default-provider-turn-adapter-smoke.php +++ b/tests/default-provider-turn-adapter-smoke.php @@ -363,8 +363,18 @@ public function get_error_data() { 'description' => 'Look up one value.', 'parameters' => array( 'type' => 'object', - 'required' => array( 'query' ), - 'properties' => array( 'query' => array( 'type' => 'string' ) ), + 'required' => array( 'query', 'scope_id' ), + 'properties' => array( + 'query' => array( 'type' => 'string' ), + 'scope_id' => array( 'type' => 'integer' ), + ), + ), + 'parameter_bindings' => array( + 'scope_id' => array( + 'source' => 'caller_context', + 'path' => 'scope.id', + 'authoritative' => true, + ), ), 'executor' => 'client', 'scope' => 'run', @@ -390,6 +400,9 @@ public function get_error_data() { 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 ); + $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 ); agents_api_smoke_assert_equals( 600.0, $GLOBALS['__adapter_smoke']['request_timeout'] ?? null, 'run_turn applies the raised 600s agentic per-request timeout to the builder by default', $failures, $passes ); echo "\n[1b] request timeout is configurable and honors a caller-supplied override + filter:\n"; diff --git a/tests/provider-turn-adapter-smoke.php b/tests/provider-turn-adapter-smoke.php index 9963d04..49c08f7 100644 --- a/tests/provider-turn-adapter-smoke.php +++ b/tests/provider-turn-adapter-smoke.php @@ -28,7 +28,21 @@ 'type' => 'object', 'required' => array( 'query' ), 'properties' => array( - 'query' => array( 'type' => 'string' ), + 'query' => array( 'type' => 'string' ), + 'scope_id' => array( 'type' => 'integer' ), + ), + 'allOf' => array( + array( + 'properties' => array( 'scope_id' => array( 'type' => 'integer' ) ), + 'required' => array( 'scope_id' ), + ), + ), + ), + 'parameter_bindings' => array( + 'scope_id' => array( + 'source' => 'caller_context', + 'path' => 'scope.id', + 'authoritative' => true, ), ), 'executor' => 'client', @@ -67,6 +81,10 @@ public function executeWP_Agent_Tool_Call( array $tool_call, array $tool_definit agents_api_smoke_assert_equals( AgentsAPI\AI\WP_Agent_Message::SCHEMA, $request->messages()[0]['schema'], 'provider request messages normalize to canonical envelopes', $failures, $passes ); agents_api_smoke_assert_equals( 'client/lookup', array_key_first( $request->toolDeclarations() ), 'provider request carries keyed tool declarations', $failures, $passes ); +$provider_tool_schema = $request->toolDeclarations()['client/lookup']['parameters'] ?? array(); +agents_api_smoke_assert_equals( false, array_key_exists( 'scope_id', $provider_tool_schema['properties'] ?? array() ), 'provider request boundary removes authoritative model properties', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 'scope_id', $provider_tool_schema['allOf'][0]['properties'] ?? array() ), 'provider request boundary sanitizes composed model schemas', $failures, $passes ); +agents_api_smoke_assert_equals( array(), $provider_tool_schema['allOf'][0]['required'] ?? null, 'provider request boundary removes composed authoritative requirements', $failures, $passes ); agents_api_smoke_assert_equals( 'fake-provider', $request->model()['provider_id'], 'provider request carries provider metadata', $failures, $passes ); agents_api_smoke_assert_equals( 'fake-runtime', $request->runtime()['runtime_id'], 'provider request carries runtime metadata', $failures, $passes ); agents_api_smoke_assert_equals( 'run-123', $request->runId(), 'provider request carries run id', $failures, $passes ); @@ -247,6 +265,9 @@ public function run_turn( AgentsAPI\AI\WP_Agent_Provider_Turn_Request $request ) agents_api_smoke_assert_equals( 'smoke', $adapter->requests[0]['runtime']['request_kind'], 'provider adapter receives runtime metadata', $failures, $passes ); agents_api_smoke_assert_equals( 'run-loop-1', $adapter->requests[0]['run_id'], 'provider adapter receives run id', $failures, $passes ); agents_api_smoke_assert_equals( 'session-loop-1', $adapter->requests[0]['session_id'], 'provider adapter receives session id', $failures, $passes ); +$custom_adapter_schema = $adapter->requests[0]['tool_declarations']['client/lookup']['parameters'] ?? array(); +agents_api_smoke_assert_equals( false, array_key_exists( 'scope_id', $custom_adapter_schema['properties'] ?? array() ), 'custom provider adapters receive sanitized model properties', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 'scope_id', $custom_adapter_schema['allOf'][0]['properties'] ?? array() ), 'custom provider adapters receive sanitized composed schemas', $failures, $passes ); agents_api_smoke_assert_equals( 1, count( $executor->executed ), 'loop mediated adapter tool call through executor', $failures, $passes ); agents_api_smoke_assert_equals( 'call-provider-1', $executor->executed[0]['id'] ?? '', 'loop preserved provider tool call id', $failures, $passes ); agents_api_smoke_assert_equals( 'Final answer from fake adapter.', $result['final_content'], 'loop surfaces adapter final assistant content', $failures, $passes ); diff --git a/tests/tool-runtime-smoke.php b/tests/tool-runtime-smoke.php index 64c4f49..b5a8f66 100644 --- a/tests/tool-runtime-smoke.php +++ b/tests/tool-runtime-smoke.php @@ -283,6 +283,173 @@ static function () { ); agents_api_smoke_assert_equals( 'explicit', $default_explicit['query'] ?? null, 'explicit runtime parameters override bindings and defaults', $failures, $passes ); +$authoritative_definition = AgentsAPI\AI\Tools\WP_Agent_Tool_Declaration::normalizeForServer( + array( + 'name' => 'ability/inspect_scope', + 'source' => 'abilities', + 'description' => 'Inspect a host-controlled scope.', + 'parameters' => array( + 'type' => 'object', + 'required' => array( 'scope_id', 'query' ), + 'properties' => array( + 'scope_id' => array( 'type' => 'integer' ), + 'query' => array( 'type' => 'string' ), + ), + ), + 'parameter_bindings' => array( + 'scope_id' => array( + 'source' => 'caller_context', + 'path' => 'scope.id', + 'authoritative' => true, + ), + 'query' => array( + 'source' => 'client_context', + 'path' => 'query', + ), + ), + ) +); +$authoritative_parameters = AgentsAPI\AI\Tools\WP_Agent_Tool_Parameters::buildParameters( + array( 'scope_id' => 999, 'query' => 'caller query' ), + array( + 'caller_context' => array( 'scope' => array( 'id' => 7 ) ), + 'client_context' => array( 'query' => 'context query' ), + ), + $authoritative_definition +); +agents_api_smoke_assert_equals( 7, $authoritative_parameters['scope_id'] ?? null, 'authoritative bindings override runtime parameters', $failures, $passes ); +agents_api_smoke_assert_equals( 'caller query', $authoritative_parameters['query'] ?? null, 'ordinary bindings remain runtime-overridable alongside authoritative bindings', $failures, $passes ); + +$authoritative_zero = AgentsAPI\AI\Tools\WP_Agent_Tool_Parameters::buildParameters( + array( 'scope_id' => 999 ), + array( 'caller_context' => array( 'scope' => array( 'id' => 0 ) ) ), + $authoritative_definition +); +agents_api_smoke_assert_equals( 0, $authoritative_zero['scope_id'] ?? null, 'authoritative bindings preserve explicit integer zero', $failures, $passes ); + +$authoritative_false_definition = $authoritative_definition; +$authoritative_false_definition['parameter_bindings'] = array( + 'enabled' => array( + 'source' => 'runtime_context', + 'path' => 'enabled', + 'authoritative' => true, + ), +); +$authoritative_false = AgentsAPI\AI\Tools\WP_Agent_Tool_Parameters::buildParameters( + array( 'enabled' => true ), + array( 'runtime_context' => array( 'enabled' => false ) ), + $authoritative_false_definition +); +agents_api_smoke_assert_equals( false, $authoritative_false['enabled'] ?? null, 'authoritative bindings preserve explicit false', $failures, $passes ); + +$authoritative_null = AgentsAPI\AI\Tools\WP_Agent_Tool_Parameters::buildParameters( + array( 'enabled' => true ), + array( 'runtime_context' => array( 'enabled' => null ) ), + $authoritative_false_definition +); +agents_api_smoke_assert_equals( true, array_key_exists( 'enabled', $authoritative_null ), 'authoritative bindings distinguish explicit null from an absent path', $failures, $passes ); +agents_api_smoke_assert_equals( null, $authoritative_null['enabled'], 'authoritative bindings preserve explicit null', $failures, $passes ); + +$authoritative_absent = AgentsAPI\AI\Tools\WP_Agent_Tool_Parameters::buildParameters( + array( 'scope_id' => 999 ), + array(), + $authoritative_definition +); +agents_api_smoke_assert_equals( false, array_key_exists( 'scope_id', $authoritative_absent ), 'absent authoritative context rejects a runtime-supplied substitute', $failures, $passes ); + +$authoritative_default_definition = array( + 'parameter_defaults' => array( 'scope_id' => 2 ), + 'parameter_bindings' => array( + 'scope_id' => array( + 'source' => 'caller_context', + 'path' => 'scope.id', + 'default' => 3, + 'authoritative' => true, + ), + ), +); +$authoritative_default = AgentsAPI\AI\Tools\WP_Agent_Tool_Parameters::buildParameters( + array( 'scope_id' => 999 ), + array(), + $authoritative_default_definition +); +agents_api_smoke_assert_equals( 3, $authoritative_default['scope_id'] ?? null, 'authoritative binding defaults override runtime parameters when context is absent', $failures, $passes ); +$authoritative_default_context = AgentsAPI\AI\Tools\WP_Agent_Tool_Parameters::buildParameters( + array( 'scope_id' => 999 ), + array( 'caller_context' => array( 'scope' => array( 'id' => 4 ) ) ), + $authoritative_default_definition +); +agents_api_smoke_assert_equals( 4, $authoritative_default_context['scope_id'] ?? null, 'authoritative context overrides binding-local and top-level defaults', $failures, $passes ); + +$authoritative_top_default_definition = array( + 'parameter_defaults' => array( + 'scope_id' => 0, + 'enabled' => false, + 'marker' => null, + ), + 'parameter_bindings' => array( + 'scope_id' => array( 'source' => 'runtime_context', 'path' => 'scope_id', 'authoritative' => true ), + 'enabled' => array( 'source' => 'runtime_context', 'path' => 'enabled', 'authoritative' => true ), + 'marker' => array( 'source' => 'runtime_context', 'path' => 'marker', 'authoritative' => true ), + ), +); +$authoritative_top_defaults = AgentsAPI\AI\Tools\WP_Agent_Tool_Parameters::buildParameters( + array( 'scope_id' => 999, 'enabled' => true, 'marker' => 'caller' ), + array(), + $authoritative_top_default_definition +); +agents_api_smoke_assert_equals( 0, $authoritative_top_defaults['scope_id'] ?? null, 'authoritative bindings preserve an explicit zero top-level default', $failures, $passes ); +agents_api_smoke_assert_equals( false, $authoritative_top_defaults['enabled'] ?? null, 'authoritative bindings preserve an explicit false top-level default', $failures, $passes ); +agents_api_smoke_assert_equals( true, array_key_exists( 'marker', $authoritative_top_defaults ), 'authoritative bindings preserve an explicit null top-level default', $failures, $passes ); +agents_api_smoke_assert_equals( null, $authoritative_top_defaults['marker'], 'authoritative null top-level defaults override runtime parameters', $failures, $passes ); + +$model_schema = AgentsAPI\AI\Tools\WP_Agent_Tool_Parameters::modelParameterSchema( $authoritative_definition ); +agents_api_smoke_assert_equals( false, array_key_exists( 'scope_id', $model_schema['properties'] ?? array() ), 'authoritative parameters are omitted from the model input schema', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'query' ), $model_schema['required'] ?? array(), 'authoritative parameters are omitted from the model required list', $failures, $passes ); + +$composed_schema_definition = $authoritative_definition; +$composed_schema_definition['parameters'] = array( + 'type' => 'object', + 'allOf' => array( + array( + 'properties' => array( + 'scope_id' => array( 'type' => 'integer' ), + 'query' => array( 'type' => 'string' ), + ), + 'required' => array( 'scope_id', 'query' ), + ), + array( + 'anyOf' => array( + array( + 'properties' => array( + 'scope_id' => array( 'type' => 'integer' ), + 'mode' => array( 'type' => 'string' ), + ), + 'required' => array( 'scope_id', 'mode' ), + ), + array( + 'oneOf' => array( + array( + 'properties' => array( + 'scope_id' => array( 'type' => 'integer' ), + 'limit' => array( 'type' => 'integer' ), + ), + 'required' => array( 'scope_id', 'limit' ), + ), + ), + ), + ), + ), + ), +); +$composed_model_schema = AgentsAPI\AI\Tools\WP_Agent_Tool_Parameters::modelParameterSchema( $composed_schema_definition ); +agents_api_smoke_assert_equals( false, array_key_exists( 'scope_id', $composed_model_schema['allOf'][0]['properties'] ?? array() ), 'allOf branches omit authoritative parameters', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'query' ), $composed_model_schema['allOf'][0]['required'] ?? array(), 'allOf branches omit authoritative requirements', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 'scope_id', $composed_model_schema['allOf'][1]['anyOf'][0]['properties'] ?? array() ), 'nested anyOf branches omit authoritative parameters', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'mode' ), $composed_model_schema['allOf'][1]['anyOf'][0]['required'] ?? array(), 'nested anyOf branches omit authoritative requirements', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 'scope_id', $composed_model_schema['allOf'][1]['anyOf'][1]['oneOf'][0]['properties'] ?? array() ), 'nested oneOf branches omit authoritative parameters', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'limit' ), $composed_model_schema['allOf'][1]['anyOf'][1]['oneOf'][0]['required'] ?? array(), 'nested oneOf branches omit authoritative requirements', $failures, $passes ); + $malformed_rejected = false; try { AgentsAPI\AI\Tools\WP_Agent_Tool_Declaration::normalizeForServer( @@ -300,6 +467,40 @@ static function () { } agents_api_smoke_assert_equals( true, $malformed_rejected, 'malformed parameter bindings are rejected with field-scoped errors', $failures, $passes ); +$unknown_binding_metadata_rejected = false; +try { + AgentsAPI\AI\Tools\WP_Agent_Tool_Declaration::normalizeForServer( + array( + 'name' => 'ability/unknown_binding_metadata', + 'source' => 'abilities', + 'description' => 'Reject unknown binding metadata.', + 'parameter_bindings' => array( + 'scope_id' => array( 'source' => 'context', 'path' => 'scope_id', 'priority' => 'host' ), + ), + ) + ); +} catch ( InvalidArgumentException $error ) { + $unknown_binding_metadata_rejected = false !== strpos( $error->getMessage(), 'parameter_bindings' ); +} +agents_api_smoke_assert_equals( true, $unknown_binding_metadata_rejected, 'unknown parameter binding metadata is rejected', $failures, $passes ); + +$malformed_authoritative_rejected = false; +try { + AgentsAPI\AI\Tools\WP_Agent_Tool_Declaration::normalizeForServer( + array( + 'name' => 'ability/malformed_authoritative_binding', + 'source' => 'abilities', + 'description' => 'Reject malformed authoritative metadata.', + 'parameter_bindings' => array( + 'scope_id' => array( 'source' => 'context', 'path' => 'scope_id', 'authoritative' => 'yes' ), + ), + ) + ); +} catch ( InvalidArgumentException $error ) { + $malformed_authoritative_rejected = false !== strpos( $error->getMessage(), 'parameter_bindings' ); +} +agents_api_smoke_assert_equals( true, $malformed_authoritative_rejected, 'non-boolean authoritative metadata is rejected', $failures, $passes ); + $sensitive_definition = array( 'parameter_bindings' => array( 'api_key' => array( @@ -352,6 +553,24 @@ public function executeWP_Agent_Tool_Call( array $tool_call, array $tool_definit }; $executor = new AgentsAPI\AI\Tools\WP_Agent_Tool_Execution_Core(); +$authoritative_prepared = $executor->prepareWP_Agent_Tool_Call( + 'ability/inspect_scope', + array( 'scope_id' => 999, 'query' => 'inspect' ), + array( 'ability/inspect_scope' => $authoritative_definition ), + array( 'caller_context' => array( 'scope' => array( 'id' => 7 ) ) ) +); +agents_api_smoke_assert_equals( true, $authoritative_prepared['ready'] ?? false, 'execution preparation accepts an authoritative context-bound parameter', $failures, $passes ); +agents_api_smoke_assert_equals( 7, $authoritative_prepared['tool_call']['parameters']['scope_id'] ?? null, 'execution preparation keeps the authoritative value over model input', $failures, $passes ); + +$authoritative_missing = $executor->prepareWP_Agent_Tool_Call( + 'ability/inspect_scope', + array( 'scope_id' => 999, 'query' => 'inspect' ), + array( 'ability/inspect_scope' => $authoritative_definition ), + array() +); +agents_api_smoke_assert_equals( false, $authoritative_missing['ready'] ?? true, 'execution preparation fails required validation when authoritative context is absent', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'scope_id' ), $authoritative_missing['metadata']['missing_parameters'] ?? array(), 'missing authoritative context reports the bound parameter without accepting model input', $failures, $passes ); + $missing = $executor->executeTool( 'local/summarize', array(), $tools, $adapter, array( 'request_id' => 'req-123' ) ); agents_api_smoke_assert_equals( false, $missing['success'], 'execution returns normalized error for missing parameters', $failures, $passes ); agents_api_smoke_assert_equals( array( 'text' ), $missing['metadata']['missing_parameters'], 'execution error includes missing parameter metadata', $failures, $passes );