diff --git a/agents-api.php b/agents-api.php index 4210bd9..06a6899 100644 --- a/agents-api.php +++ b/agents-api.php @@ -251,6 +251,7 @@ require_once AGENTS_API_PATH . 'src/Tools/class-wp-agent-tool-result.php'; require_once AGENTS_API_PATH . 'src/Tools/class-wp-agent-tool-executor.php'; require_once AGENTS_API_PATH . 'src/Tools/class-wp-agent-ability-tool-executor.php'; +require_once AGENTS_API_PATH . 'src/Tools/class-wp-agent-default-chat-tool-executor.php'; require_once AGENTS_API_PATH . 'src/Tools/class-wp-agent-tool-executor-registry.php'; require_once AGENTS_API_PATH . 'src/Tools/class-wp-agent-tool-execution-core.php'; require_once AGENTS_API_PATH . 'src/Tools/class-wp-agent-tool-source-registry.php'; diff --git a/src/Channels/register-agents-chat-ability.php b/src/Channels/register-agents-chat-ability.php index dae337f..287a8c2 100644 --- a/src/Channels/register-agents-chat-ability.php +++ b/src/Channels/register-agents-chat-ability.php @@ -713,6 +713,9 @@ function agents_chat_output_schema(): array { 'type' => 'boolean', 'description' => 'Whether the agent considers this turn complete (true) or expects further work (false, e.g. tool approvals pending).', ), + 'status' => agents_chat_status_schema(), + 'runtime_tool_pending' => agents_chat_runtime_tool_pending_schema(), + 'run_outcome' => agents_chat_run_outcome_schema(), 'metadata' => array( 'type' => 'object', 'description' => 'Runtime metadata. The default handler exposes `agents_api.tool_observability`, a content-redacted v1 tool lifecycle contract.', @@ -767,6 +770,68 @@ function agents_chat_output_schema(): array { ); } +/** + * Canonical terminal or suspended chat status. + * + * @return array + */ +function agents_chat_status_schema(): array { + return array( + 'type' => 'string', + 'enum' => \AgentsAPI\AI\WP_Agent_Run_Outcome::statuses(), + 'description' => 'Canonical result status. Omitted only when a legacy handler does not report a status.', + ); +} + +/** + * Canonical pending external runtime-tool request. + * + * @return array + */ +function agents_chat_runtime_tool_pending_schema(): array { + return array( + 'type' => 'object', + 'description' => 'Present when status is runtime_tool_pending. The request is inline for stateless turns and may also be persisted by a host runtime-tool request store.', + 'required' => array( 'status', 'request_id', 'tool_name', 'tool_call_id', 'parameters', 'run_id', 'timeout_at', 'runtime', 'metadata' ), + 'properties' => array( + 'status' => array( 'type' => 'string', 'enum' => array( \AgentsAPI\AI\WP_Agent_Runtime_Tool_Request::STATUS_PENDING ) ), + 'request_id' => array( 'type' => 'string' ), + 'tool_name' => array( 'type' => 'string' ), + 'tool_call_id' => array( 'type' => 'string' ), + 'parameters' => array( 'type' => 'object' ), + 'run_id' => array( 'type' => 'string' ), + 'timeout_at' => array( 'type' => 'string' ), + 'runtime' => array( 'type' => 'object' ), + 'metadata' => array( 'type' => 'object' ), + ), + ); +} + +/** + * Canonical outcome envelope for this chat run. + * + * @return array + */ +function agents_chat_run_outcome_schema(): array { + return array( + 'type' => 'object', + 'description' => 'Versioned, runtime-neutral outcome envelope for the chat run.', + 'required' => array( 'schema', 'version', 'status', 'completed', 'stop_reason', 'retryable' ), + 'properties' => array( + 'schema' => array( 'type' => 'string', 'enum' => array( \AgentsAPI\AI\WP_Agent_Run_Outcome::SCHEMA ) ), + 'version' => array( 'type' => 'integer', 'enum' => array( \AgentsAPI\AI\WP_Agent_Run_Outcome::VERSION ) ), + 'status' => agents_chat_status_schema(), + 'completed' => array( 'type' => 'boolean' ), + 'stop_reason' => array( 'type' => 'string' ), + 'retryable' => array( 'type' => 'boolean' ), + 'failure' => array( 'type' => 'object' ), + 'assertions' => array( 'type' => 'object' ), + 'provider_error' => array( 'type' => 'object' ), + 'metadata' => array( 'type' => 'object' ), + ), + ); +} + /** * Convenience helper for consumers: register a callable as the chat handler. * diff --git a/src/Channels/register-default-agents-chat-handler.php b/src/Channels/register-default-agents-chat-handler.php index c0d5b6f..428a68c 100644 --- a/src/Channels/register-default-agents-chat-handler.php +++ b/src/Channels/register-default-agents-chat-handler.php @@ -42,6 +42,7 @@ use AgentsAPI\AI\WP_Agent_Message; use AgentsAPI\AI\WP_Agent_Runtime_Profile; use AgentsAPI\AI\Tools\WP_Agent_Ability_Tool_Executor; +use AgentsAPI\AI\Tools\WP_Agent_Default_Chat_Tool_Executor; use AgentsAPI\AI\Tools\WP_Agent_Tool_Declaration; use AgentsAPI\AI\Tools\WP_Agent_Tool_Executor_Registry; use AgentsAPI\Core\Database\Chat\WP_Agent_Conversation_Sessions; @@ -201,13 +202,21 @@ private static function execute_native( array $input, ?callable $delta_sink = nu 'agent_slug' => $agent_slug, 'session_id' => $session_id, 'run_id' => is_string( $input['run_id'] ?? null ) ? trim( $input['run_id'] ) : '', + 'principal' => $input['principal'] ?? null, 'runtime_profile' => $runtime_profile instanceof WP_Agent_Runtime_Profile ? $runtime_profile->to_array() : null, + 'client_context' => self::runtime_client_context( $input ), ); $executor_registry = WP_Agent_Tool_Executor_Registry::fromFilters( $runtime_context ); $tool_declarations = self::resolve_tool_declarations( $config, self::runtime_tool_declarations( $agent, $runtime_context ), $executor_registry ); if ( is_wp_error( $tool_declarations ) ) { return $tool_declarations; } + $trusted_runtime_tools = array_keys( + array_filter( + $tool_declarations, + static fn( array $declaration ): bool => WP_Agent_Tool_Declaration::EXECUTOR_CLIENT === ( $declaration['executor'] ?? null ) + ) + ); $tool_declarations = ( new \WP_Agent_Tool_Policy() )->resolve( $tool_declarations, array( @@ -215,6 +224,9 @@ private static function execute_native( array $input, ?callable $delta_sink = nu 'allow_only' => is_array( $input['allow_only'] ?? null ) ? $input['allow_only'] : array(), 'tool_policy' => is_array( $input['tool_policy'] ?? null ) ? $input['tool_policy'] : array(), 'principal' => $input['principal'] ?? null, + // These client tools were added by the server-only declaration filter, + // not supplied by the caller, so they are trusted policy opt-ins. + 'runtime_tools' => $trusted_runtime_tools, ) ); @@ -242,7 +254,11 @@ private static function execute_native( array $input, ?callable $delta_sink = nu // The loop also consults the #377 per-target executor registry // (`agents_api_tool_executors`) for declarations that select another // execution environment, so consumers can override per tool target. - $loop_options['tool_executor'] = new WP_Agent_Ability_Tool_Executor(); + $loop_options['tool_executor'] = new WP_Agent_Default_Chat_Tool_Executor( new WP_Agent_Ability_Tool_Executor() ); + } + $runtime_tool_store = \AgentsAPI\AI\agents_runtime_tool_request_store_optional( $runtime_context ); + if ( null !== $runtime_tool_store ) { + $loop_options['runtime_tool_request_store'] = $runtime_tool_store; } if ( ! empty( $tool_call_rules ) ) { // Declarative deterministic tool-call gating. The loop enforces these @@ -437,8 +453,10 @@ private static function resolve_system_prompt( array $config ): string { * Runtime overlays come only from the server-side * `agents_api_runtime_tool_declarations` filter after the agent, generated * session id, and run id are resolved. Each entry must name an existing - * canonical enabled tool. Overlays can replace only model-facing description, - * parameters, and runtime execution metadata. + * canonical enabled tool. Host overlays can replace only model-facing + * description, parameters, and runtime execution metadata. Client overlays + * are additional, request-scoped declarations and suspend at the runtime-tool + * continuation boundary instead of being dispatched as abilities. * * @param array $config Agent default config. * @param array $overlays Server-provided declaration overlays. @@ -495,6 +513,10 @@ private static function resolve_tool_declarations( array $config, array $overlay $executor_registry = $executor_registry ?? new WP_Agent_Tool_Executor_Registry(); $seen_names = array(); $seen_aliases = array(); + $reserved_aliases = array(); + foreach ( $declarations as $name => $declaration ) { + $reserved_aliases[ WP_Agent_Tool_Declaration::providerSafeName( $name ) ] = true; + } foreach ( $overlays as $map_name => $overlay ) { if ( ! is_string( $map_name ) || ! is_array( $overlay ) ) { return self::runtime_tool_declaration_error( 'declaration' ); @@ -506,18 +528,27 @@ private static function resolve_tool_declarations( array $config, array $overlay return self::runtime_tool_declaration_error( $error->getMessage() ); } - $name = $normalized['name'] ?? ''; - if ( ! is_string( $name ) || $map_name !== $name || ! isset( $declarations[ $name ] ) || isset( $seen_names[ $name ] ) ) { + $name = $normalized['name'] ?? ''; + $is_client = WP_Agent_Tool_Declaration::EXECUTOR_CLIENT === ( $normalized['executor'] ?? null ); + if ( ! is_string( $name ) || $map_name !== $name || isset( $seen_names[ $name ] ) || ( ! $is_client && ! isset( $declarations[ $name ] ) ) || ( $is_client && isset( $declarations[ $name ] ) ) ) { return self::runtime_tool_declaration_error( 'name' ); } $seen_names[ $name ] = true; $alias = $normalized['provider_safe_name'] ?? WP_Agent_Tool_Declaration::providerSafeName( $name ); - if ( ! is_string( $alias ) || isset( $seen_aliases[ $alias ] ) ) { + if ( ! is_string( $alias ) || isset( $seen_aliases[ $alias ] ) || ( $is_client && isset( $reserved_aliases[ $alias ] ) ) ) { return self::runtime_tool_declaration_error( 'provider_safe_name' ); } $seen_aliases[ $alias ] = true; + if ( $is_client ) { + if ( '' !== WP_Agent_Tool_Executor_Registry::targetIdFromDeclaration( $normalized ) ) { + return self::runtime_tool_declaration_error( 'executor_target' ); + } + $declarations[ $name ] = $normalized; + continue; + } + $target_id = WP_Agent_Tool_Executor_Registry::targetIdFromDeclaration( $normalized ); $runtime = is_array( $normalized['runtime'] ?? null ) ? $normalized['runtime'] : array(); $declares_target = array_key_exists( WP_Agent_Tool_Executor_Registry::RUNTIME_EXECUTOR_TARGET, $runtime ); @@ -548,6 +579,22 @@ private static function runtime_tool_declarations( ?\WP_Agent $agent, array $run return is_array( $overlays ) ? $overlays : array( '__invalid__' => $overlays ); } + /** + * Provide trusted filters sanitized client-supplied context data. + * + * This context is not an authorization signal. A runtime declaration filter + * must establish authorization from a server-authenticated principal or its + * own trusted transport binding before it exposes a client tool. + * + * @param array $input Canonical chat input. + * @return array + */ + private static function runtime_client_context( array $input ): array { + $client_context = is_array( $input['client_context'] ?? null ) ? \AgentsAPI\AI\agents_api_string_keyed_array( $input['client_context'] ) : array(); + + return agents_chat_strip_runtime_tool_declaration_fields( $client_context ); + } + /** * Build the public error returned for a rejected trusted-runtime overlay. * @@ -756,13 +803,25 @@ private static function to_canonical_output( string $session_id, array $result, static fn( $value ): bool => null !== $value ); - return array( + $output = array( 'session_id' => $session_id, 'reply' => is_string( $result['final_content'] ?? null ) ? $result['final_content'] : '', 'messages' => self::to_canonical_messages( is_array( $result['messages'] ?? null ) ? array_values( $result['messages'] ) : array() ), 'completed' => (bool) ( $result['completed'] ?? true ), 'metadata' => array( 'agents_api' => $metadata ), ); + $run_outcome_status = is_array( $result['run_outcome'] ?? null ) && is_string( $result['run_outcome']['status'] ?? null ) ? $result['run_outcome']['status'] : null; + if ( null !== $run_outcome_status ) { + $output['status'] = $run_outcome_status; + } + if ( is_array( $result['runtime_tool_pending'] ?? null ) ) { + $output['runtime_tool_pending'] = $result['runtime_tool_pending']; + } + if ( is_array( $result['run_outcome'] ?? null ) ) { + $output['run_outcome'] = $result['run_outcome']; + } + + return $output; } /** diff --git a/src/Runtime/register-runtime-tool-lifecycle-abilities.php b/src/Runtime/register-runtime-tool-lifecycle-abilities.php index 740be31..78a054c 100644 --- a/src/Runtime/register-runtime-tool-lifecycle-abilities.php +++ b/src/Runtime/register-runtime-tool-lifecycle-abilities.php @@ -236,13 +236,7 @@ function agents_runtime_tool_terminal_request( array $input, bool $cancelled ) { * @return WP_Agent_Runtime_Tool_Request_Store|\WP_Error */ function agents_runtime_tool_request_store( array $input ) { - /** - * Filters the runtime-tool request store used by lifecycle abilities. - * - * @param WP_Agent_Runtime_Tool_Request_Store|null $store Current store, or null. - * @param array $input Ability input. - */ - $store = apply_filters( 'wp_agent_runtime_tool_request_store', null, $input ); + $store = agents_runtime_tool_request_store_optional( $input ); if ( $store instanceof WP_Agent_Runtime_Tool_Request_Store ) { return $store; @@ -254,6 +248,27 @@ function agents_runtime_tool_request_store( array $input ) { ); } +/** + * Resolve an optional host-provided runtime-tool request store. + * + * Runtimes can use this when durable lifecycle support is available without + * making a store mandatory for an otherwise stateless pending result. + * + * @param array $input Host runtime or ability context. + * @return WP_Agent_Runtime_Tool_Request_Store|null + */ +function agents_runtime_tool_request_store_optional( array $input ): ?WP_Agent_Runtime_Tool_Request_Store { + /** + * Filters the runtime-tool request store used by lifecycle abilities and runtimes. + * + * @param WP_Agent_Runtime_Tool_Request_Store|null $store Current store, or null. + * @param array $input Host runtime or ability context. + */ + $store = apply_filters( 'wp_agent_runtime_tool_request_store', null, $input ); + + return $store instanceof WP_Agent_Runtime_Tool_Request_Store ? $store : null; +} + /** * Resolve an optional host continuation adapter. * diff --git a/src/Tools/class-wp-agent-default-chat-tool-executor.php b/src/Tools/class-wp-agent-default-chat-tool-executor.php new file mode 100644 index 0000000..0cfe438 --- /dev/null +++ b/src/Tools/class-wp-agent-default-chat-tool-executor.php @@ -0,0 +1,61 @@ +host_executor = $host_executor; + } + + /** + * Suspend client-owned tools; preserve host executor behavior unchanged. + * + * @param array $tool_call Normalized tool call. + * @param array $tool_definition Selected declaration. + * @param array $context Runtime context. + * @return array + */ + public function executeWP_Agent_Tool_Call( array $tool_call, array $tool_definition, array $context = array() ): array { + if ( WP_Agent_Tool_Declaration::EXECUTOR_CLIENT !== ( $tool_definition['executor'] ?? null ) ) { + return $this->host_executor->executeWP_Agent_Tool_Call( $tool_call, $tool_definition, $context ); + } + + $tool_call = WP_Agent_Tool_Call::normalize( $tool_call ); + $tool_name = is_string( $tool_call['tool_name'] ?? null ) ? $tool_call['tool_name'] : ''; + $tool_call_id = is_string( $tool_call['id'] ?? null ) ? $tool_call['id'] : ''; + $parameters = is_array( $tool_call['parameters'] ?? null ) + ? \AgentsAPI\AI\agents_api_string_keyed_array( $tool_call['parameters'] ) + : array(); + $request = WP_Agent_Runtime_Tool_Request::from_tool_call( + $tool_name, + $tool_call_id, + $parameters, + \AgentsAPI\AI\agents_api_string_keyed_array( $context ), + WP_Agent_Tool_Declaration::normalizeRuntimeMetadata( $tool_definition['runtime'] ?? array() ) + ); + + return array( + 'success' => false, + 'tool_name' => $tool_name, + 'error' => 'Client tool execution is pending.', + 'status' => WP_Agent_Runtime_Tool_Request::STATUS_PENDING, + 'runtime_tool_request' => $request, + ); + } +} diff --git a/tests/agents-chat-ability-smoke.php b/tests/agents-chat-ability-smoke.php index b960182..f6a5c59 100644 --- a/tests/agents-chat-ability-smoke.php +++ b/tests/agents-chat-ability-smoke.php @@ -247,6 +247,12 @@ public function update_title( string $session_id, string $title ): bool { smoke_assert( array( 'session_id', 'reply' ), $out_schema['required'] ?? array(), 'output_schema_required_fields', $failures, $passes ); smoke_assert( array( 1 ), $out_schema['properties']['metadata']['properties']['agents_api']['properties']['tool_observability']['properties']['version']['enum'] ?? array(), 'output_schema_documents_tool_observability_v1', $failures, $passes ); smoke_assert( array( 'pending', 'succeeded', 'failed', 'rejected' ), $out_schema['properties']['metadata']['properties']['agents_api']['properties']['tool_observability']['properties']['calls']['items']['properties']['status']['enum'] ?? array(), 'output_schema_documents_tool_statuses', $failures, $passes ); +smoke_assert( AgentsAPI\AI\WP_Agent_Run_Outcome::statuses(), $out_schema['properties']['status']['enum'] ?? array(), 'output_schema_documents_canonical_statuses', $failures, $passes ); +smoke_assert( array( 'status', 'request_id', 'tool_name', 'tool_call_id', 'parameters', 'run_id', 'timeout_at', 'runtime', 'metadata' ), $out_schema['properties']['runtime_tool_pending']['required'] ?? array(), 'output_schema_requires_canonical_pending_request_fields', $failures, $passes ); +smoke_assert( array( AgentsAPI\AI\WP_Agent_Runtime_Tool_Request::STATUS_PENDING ), $out_schema['properties']['runtime_tool_pending']['properties']['status']['enum'] ?? array(), 'output_schema_limits_pending_request_status', $failures, $passes ); +smoke_assert( array( 'schema', 'version', 'status', 'completed', 'stop_reason', 'retryable' ), $out_schema['properties']['run_outcome']['required'] ?? array(), 'output_schema_requires_canonical_run_outcome_fields', $failures, $passes ); +smoke_assert( array( AgentsAPI\AI\WP_Agent_Run_Outcome::SCHEMA ), $out_schema['properties']['run_outcome']['properties']['schema']['enum'] ?? array(), 'output_schema_limits_run_outcome_schema', $failures, $passes ); +smoke_assert( array( AgentsAPI\AI\WP_Agent_Run_Outcome::VERSION ), $out_schema['properties']['run_outcome']['properties']['version']['enum'] ?? array(), 'output_schema_limits_run_outcome_version', $failures, $passes ); // 9. Runtime principal input is normalized before dispatch and has a scoped permission filter. smoke_reset_chat_filters(); diff --git a/tests/default-agents-chat-handler-smoke.php b/tests/default-agents-chat-handler-smoke.php index 210f1ec..05f6644 100644 --- a/tests/default-agents-chat-handler-smoke.php +++ b/tests/default-agents-chat-handler-smoke.php @@ -738,6 +738,187 @@ static function ( $config, $agent, array $input ) use ( &$runtime_config_calls ) agents_api_smoke_assert_equals( false, $mandatory_output instanceof WP_Error, 'mandatory tool overlay runs successfully', $failures, $passes ); agents_api_smoke_assert_equals( 1, count( $overlay_executor->calls ), 'mandatory tool bypasses allow_only after overlay', $failures, $passes ); + echo "\n[1e] Trusted runtime filters add request-scoped client tools that suspend safely:\n"; + $client_overlay_contexts = array(); + $trusted_client_principal = \AgentsAPI\AI\WP_Agent_Execution_Principal::runtime( + 'authenticated-client-runtime', + 'client-brain', + array(), + 'client-workspace', + 'frontend-chat' + )->to_array(); + $runtime_tool_store = new class() implements \AgentsAPI\AI\WP_Agent_Runtime_Tool_Request_Atomic_Store { + /** @var array> */ + public array $requests = array(); + + public function create( array $request ): void { + $this->requests[ $request['request_id'] ] = $request; + } + + public function get( string $request_id ): ?array { + return $this->requests[ $request_id ] ?? null; + } + + public function complete( string $request_id, array $result ): void { + $this->transition_pending( $request_id, \AgentsAPI\AI\WP_Agent_Runtime_Tool_Request::STATUS_COMPLETED, $result ); + } + + public function timeout( string $request_id ): void { + $this->transition_pending( $request_id, \AgentsAPI\AI\WP_Agent_Runtime_Tool_Request::STATUS_TIMEOUT ); + } + + public function transition_pending( string $request_id, string $status, ?array $result = null ): bool { + if ( \AgentsAPI\AI\WP_Agent_Runtime_Tool_Request::STATUS_PENDING !== ( $this->requests[ $request_id ]['status'] ?? '' ) ) { + return false; + } + $this->requests[ $request_id ]['status'] = $status; + if ( null !== $result ) { + $this->requests[ $request_id ]['result'] = $result; + } + return true; + } + + public function recent_pending( array $query = array() ): array { + unset( $query ); + return array_values( $this->requests ); + } + }; + add_filter( + 'agents_api_runtime_tool_declarations', + static function ( array $declarations, $agent, array $context ) use ( &$client_overlay_contexts ): array { + unset( $agent ); + $principal = is_array( $context['principal'] ?? null ) ? $context['principal'] : array(); + if ( + 'client-brain' !== ( $context['agent_slug'] ?? '' ) + || \AgentsAPI\AI\WP_Agent_Execution_Principal::AUTH_SOURCE_RUNTIME !== ( $principal['auth_source'] ?? null ) + || \AgentsAPI\AI\WP_Agent_Execution_Principal::REQUEST_CONTEXT_RUNTIME !== ( $principal['request_context'] ?? null ) + ) { + return $declarations; + } + + $client_overlay_contexts[] = $context; + $declarations['client/notify'] = array( + 'name' => 'client/notify', + 'source' => 'client', + 'description' => 'Notify the active client.', + 'parameters' => array( + 'type' => 'object', + 'required' => array( 'message' ), + 'properties' => array( 'message' => array( 'type' => 'string' ) ), + ), + 'executor' => 'client', + 'scope' => 'run', + ); + if ( true === ( $context['client_context']['client_alias_collision'] ?? false ) ) { + $declarations['client/notify--copy'] = array( + 'name' => 'client/notify--copy', + 'source' => 'client', + 'description' => 'A colliding client tool alias.', + 'parameters' => array(), + 'executor' => 'client', + 'scope' => 'run', + ); + $declarations['client/notify__copy'] = array( + 'name' => 'client/notify__copy', + 'source' => 'client', + 'description' => 'Another colliding client tool alias.', + 'parameters' => array(), + 'executor' => 'client', + 'scope' => 'run', + ); + } + return $declarations; + }, + 20, + 3 + ); + add_filter( + 'wp_agent_runtime_tool_request_store', + static function ( $store, array $input ) use ( $runtime_tool_store ) { + return 'client-runtime-run' === ( $input['run_id'] ?? '' ) || isset( $input['request_id'] ) ? $runtime_tool_store : $store; + }, + 10, + 2 + ); + $registry->register( + 'client-brain', + array( + 'label' => 'Client Brain', + 'default_config' => array( 'provider' => 'fake-provider', 'model' => 'fake-model' ), + ) + ); + $GLOBALS['__chat_handler_ability_calls'] = array(); + $GLOBALS['__adapter_smoke'] = array( + 'turn' => 0, + 'results_by_turn' => array( + 1 => $make_result( '', array( array( 'name' => 'client__notify', 'parameters' => array( 'message' => 'Dinner is ready.' ), 'id' => 'client-call-1' ) ), array( 3, 1, 4 ) ), + ), + ); + $untrusted_client_output = AgentsAPI\AI\Channels\WP_Agent_Default_Chat_Handler::execute( + array( 'agent' => 'client-brain', 'message' => 'Do not trust this caller flag.', 'client_context' => array( 'enable_client_notify' => true ) ) + ); + $untrusted_declaration_names = array_map( static fn( $declaration ): string => $declaration->name, $GLOBALS['__adapter_smoke']['declarations'] ?? array() ); + agents_api_smoke_assert_equals( false, in_array( 'client/notify', $untrusted_declaration_names, true ), 'caller client_context flags do not authorize client tools', $failures, $passes ); + + $GLOBALS['__adapter_smoke'] = array( + 'turn' => 0, + 'results_by_turn' => array( + 1 => $make_result( '', array( array( 'name' => 'client__notify', 'parameters' => array( 'message' => 'Dinner is ready.' ), 'id' => 'client-call-1' ) ), array( 3, 1, 4 ) ), + ), + ); + $client_output = AgentsAPI\AI\Channels\WP_Agent_Default_Chat_Handler::execute( + array( + 'agent' => 'client-brain', + 'message' => 'Notify me.', + 'run_id' => 'client-runtime-run', + 'principal' => $trusted_client_principal, + 'client_context' => array( + 'enable_client_notify' => true, + 'runtime_tool_declarations' => array( 'client/attacker' => array( 'name' => 'client/attacker' ) ), + ), + ) + ); + $client_declaration_names = array_map( static fn( $declaration ): string => $declaration->name, $GLOBALS['__adapter_smoke']['declarations'] ?? array() ); + $pending_client_tool = $client_output['runtime_tool_pending'] ?? array(); + agents_api_smoke_assert_equals( true, in_array( 'client/notify', $client_declaration_names, true ), 'the model receives the trusted client declaration', $failures, $passes ); + agents_api_smoke_assert_equals( false, array_key_exists( 'runtime_tool_declarations', $client_overlay_contexts[0]['client_context'] ?? array() ), 'runtime declaration fields remain stripped before the trusted filter', $failures, $passes ); + agents_api_smoke_assert_equals( false, $client_output['completed'] ?? true, 'a client tool call leaves the chat turn incomplete', $failures, $passes ); + agents_api_smoke_assert_equals( 'runtime_tool_pending', $client_output['status'] ?? '', 'a client tool call returns the canonical pending status', $failures, $passes ); + agents_api_smoke_assert_equals( 'runtime_tool_pending', $client_output['run_outcome']['status'] ?? '', 'the canonical run outcome is pending', $failures, $passes ); + agents_api_smoke_assert_equals( 'client/notify', $pending_client_tool['tool_name'] ?? '', 'pending client request retains the canonical tool name', $failures, $passes ); + agents_api_smoke_assert_equals( 'client-call-1', $pending_client_tool['tool_call_id'] ?? '', 'pending client request retains the provider tool call id', $failures, $passes ); + agents_api_smoke_assert_equals( array( 'message' => 'Dinner is ready.' ), $pending_client_tool['parameters'] ?? array(), 'pending client request retains the prepared parameters', $failures, $passes ); + agents_api_smoke_assert_equals( 'client-runtime-run', $pending_client_tool['run_id'] ?? '', 'pending client request retains the canonical run id', $failures, $passes ); + agents_api_smoke_assert_equals( 0, count( $GLOBALS['__chat_handler_ability_calls'] ), 'a client tool call never reaches the ability executor', $failures, $passes ); + agents_api_smoke_assert_equals( $pending_client_tool, $runtime_tool_store->get( $pending_client_tool['request_id'] ?? '' ), 'host-provided request store persists the pending client tool', $failures, $passes ); + agents_api_smoke_assert_equals( $runtime_tool_store, \AgentsAPI\AI\agents_runtime_tool_request_store( array( 'request_id' => $pending_client_tool['request_id'] ?? '' ) ), 'generic lifecycle abilities resolve the same host request store', $failures, $passes ); + $submission = \AgentsAPI\AI\agents_runtime_tool_submit_result( array( 'request_id' => $pending_client_tool['request_id'] ?? '', 'success' => true, 'result' => array( 'delivered' => true ), 'resume' => false ) ); + agents_api_smoke_assert_equals( \AgentsAPI\AI\WP_Agent_Runtime_Tool_Result::STATUS_SUBMITTED, $submission['status'] ?? '', 'generic submit lifecycle addresses the persisted client request', $failures, $passes ); + agents_api_smoke_assert_equals( \AgentsAPI\AI\WP_Agent_Runtime_Tool_Request::STATUS_COMPLETED, $runtime_tool_store->get( $pending_client_tool['request_id'] ?? '' )['status'] ?? '', 'generic submit lifecycle completes the persisted client request', $failures, $passes ); + $output_schema = \AgentsAPI\AI\Channels\agents_chat_output_schema(); + $pending_schema = $output_schema['properties']['runtime_tool_pending'] ?? array(); + $outcome_schema = $output_schema['properties']['run_outcome'] ?? array(); + agents_api_smoke_assert_equals( 'string', $output_schema['properties']['status']['type'] ?? '', 'output schema types the top-level status', $failures, $passes ); + agents_api_smoke_assert_equals( array(), array_diff( $pending_schema['required'] ?? array(), array_keys( $pending_client_tool ) ), 'pending output satisfies its required schema fields', $failures, $passes ); + agents_api_smoke_assert_equals( array( 'runtime_tool_pending' ), $pending_schema['properties']['status']['enum'] ?? array(), 'pending schema constrains the canonical pending status', $failures, $passes ); + agents_api_smoke_assert_equals( 'agents-api.run-outcome', $outcome_schema['properties']['schema']['enum'][0] ?? '', 'run outcome schema constrains its reusable envelope', $failures, $passes ); + + $GLOBALS['__adapter_smoke'] = array( + 'turn' => 0, + 'results_by_turn' => array( + 1 => $make_result( '', array( array( 'name' => 'client__notify', 'parameters' => array( 'message' => 'No id.' ), 'id' => '' ) ), array( 3, 1, 4 ) ), + ), + ); + $missing_id_output = AgentsAPI\AI\Channels\WP_Agent_Default_Chat_Handler::execute( + array( 'agent' => 'client-brain', 'message' => 'Notify without an id.', 'principal' => $trusted_client_principal ) + ); + agents_api_smoke_assert_equals( 'tool-call-1-1', $missing_id_output['runtime_tool_pending']['tool_call_id'] ?? '', 'a missing provider tool-call id receives the canonical generated id', $failures, $passes ); + + $alias_collision_output = AgentsAPI\AI\Channels\WP_Agent_Default_Chat_Handler::execute( + array( 'agent' => 'client-brain', 'message' => 'Reject colliding aliases.', 'principal' => $trusted_client_principal, 'client_context' => array( 'client_alias_collision' => true ) ) + ); + agents_api_smoke_assert_equals( 'agents_chat_invalid_runtime_tool_declaration', $alias_collision_output instanceof WP_Error ? $alias_collision_output->get_error_code() : '', 'client declarations with colliding provider-safe aliases are rejected', $failures, $passes ); + echo "\n[2] Native chat resolves host runtime profiles and publishes safe provenance:\n"; $profile_provider = new Agents_Chat_Runtime_Profile_Provider(); $profile_turn_contexts = array();