Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Runtime/class-wp-agent-provider-turn-request.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}

Expand Down
138 changes: 126 additions & 12 deletions src/Tools/class-wp-agent-tool-parameters.php
Original file line number Diff line number Diff line change
Expand Up @@ -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:
*
Expand All @@ -45,22 +45,32 @@ 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<mixed> $tool_parameters Runtime tool-call parameters.
* @param array<mixed> $context Host runtime context for this invocation.
* @param array<mixed> $tool_definition Normalized tool declaration.
* @return array<mixed> 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 ) ) {
Expand All @@ -79,14 +89,31 @@ 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;
}

/**
* Normalize explicit parameter bindings, including legacy client-context bindings.
*
* @param array<mixed> $tool_definition Normalized tool declaration.
* @return array<string, array{source: string, path: string, sensitive?: bool, default?: mixed}>
* @return array<string, array{source: string, path: string, sensitive?: bool, default?: mixed, authoritative?: bool}>
*/
public static function normalizeParameterBindings( array $tool_definition ): array {
$normalized = array();
Expand Down Expand Up @@ -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<mixed> $tool_definition Normalized tool declaration.
* @return array<string, array{source: string, path: string, sensitive?: bool, default?: mixed}>
* @return array<string, array{source: string, path: string, sensitive?: bool, default?: mixed, authoritative?: bool}>
*/
private static function parameterBindings( array $tool_definition ): array {
try {
Expand Down Expand Up @@ -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 ) ) {
Expand All @@ -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 ] ) ) {
Expand Down Expand Up @@ -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<mixed> $tool_definition Normalized tool declaration.
* @return array<mixed> 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<mixed> $schema Parameter schema node.
* @param array<string,bool> $authoritative Authoritative parameter names.
* @return array<mixed> 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.
*
Expand Down
17 changes: 15 additions & 2 deletions tests/default-provider-turn-adapter-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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";
Expand Down
23 changes: 22 additions & 1 deletion tests/provider-turn-adapter-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -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 );
Expand Down
Loading