From 68460e82db3a2fc4b28b96964893478bd7f2d974 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 23 Jun 2026 00:01:24 -0400 Subject: [PATCH] Add JSON-RPC task method compatibility --- .../register-agents-chat-jsonrpc-route.php | 79 ++++++++++++++++--- tests/agents-chat-jsonrpc-route-smoke.php | 56 ++++++++++++- 2 files changed, 121 insertions(+), 14 deletions(-) diff --git a/src/Channels/register-agents-chat-jsonrpc-route.php b/src/Channels/register-agents-chat-jsonrpc-route.php index d68a9ef..1320ba0 100644 --- a/src/Channels/register-agents-chat-jsonrpc-route.php +++ b/src/Channels/register-agents-chat-jsonrpc-route.php @@ -5,6 +5,8 @@ * Exposes the canonical agents/chat ability over a JSON-RPC 2.0 wire keyed by * agent id, so protocol clients that speak `message/send` (request/response) * and `message/stream` (Server-Sent Events) can drive a registered runtime. + * Legacy Agent Protocol task method names (`tasks/send`, `tasks/sendSubscribe`) + * are accepted as aliases for compatibility with older clients. * * The route is intentionally a thin envelope: `message/send` is one synchronous * agents/chat call wrapped in a Task; `message/stream` emits the same Task over @@ -30,11 +32,13 @@ defined( 'ABSPATH' ) || exit; -const AGENTS_CHAT_JSONRPC_NAMESPACE = 'agents-api/v1'; -const AGENTS_CHAT_JSONRPC_ROUTE = '/agent/(?P[A-Za-z0-9._-]+)'; -const AGENTS_CHAT_JSONRPC_VERSION = '2.0'; -const AGENTS_CHAT_JSONRPC_METHOD_SEND = 'message/send'; -const AGENTS_CHAT_JSONRPC_METHOD_STREAM = 'message/stream'; +const AGENTS_CHAT_JSONRPC_NAMESPACE = 'agents-api/v1'; +const AGENTS_CHAT_JSONRPC_ROUTE = '/agent/(?P[A-Za-z0-9._-]+)'; +const AGENTS_CHAT_JSONRPC_VERSION = '2.0'; +const AGENTS_CHAT_JSONRPC_METHOD_SEND = 'message/send'; +const AGENTS_CHAT_JSONRPC_METHOD_STREAM = 'message/stream'; +const AGENTS_CHAT_JSONRPC_METHOD_TASKS_SEND = 'tasks/send'; +const AGENTS_CHAT_JSONRPC_METHOD_TASKS_SEND_SUBSCRIBE = 'tasks/sendSubscribe'; // JSON-RPC 2.0 reserved error codes (see the spec + agenttic-client ErrorCodes). const AGENTS_CHAT_JSONRPC_ERR_PARSE = -32700; @@ -167,20 +171,20 @@ function agents_chat_jsonrpc_dispatch( \WP_REST_Request $request ): \WP_REST_Res ); } - $input = agents_chat_jsonrpc_input_from_params( $params, $agent ); + $input = agents_chat_jsonrpc_input_from_params( $params, $agent, $body ); if ( is_wp_error( $input ) ) { return rest_ensure_response( agents_chat_jsonrpc_error_frame( $rpc_id, AGENTS_CHAT_JSONRPC_ERR_INVALID_PARAMS, $input->get_error_message() ) ); } - if ( AGENTS_CHAT_JSONRPC_METHOD_STREAM === $method ) { + if ( agents_chat_jsonrpc_method_streams( $method ) ) { // Streams directly and exits; never returns to the REST server. agents_chat_jsonrpc_stream( $rpc_id, $input ); exit; } - if ( AGENTS_CHAT_JSONRPC_METHOD_SEND !== $method ) { + if ( ! agents_chat_jsonrpc_method_sends( $method ) ) { return rest_ensure_response( agents_chat_jsonrpc_error_frame( $rpc_id, AGENTS_CHAT_JSONRPC_ERR_METHOD_NOT_FOUND, sprintf( 'Unknown JSON-RPC method "%s".', $method ) ) ); @@ -198,6 +202,24 @@ function agents_chat_jsonrpc_dispatch( \WP_REST_Request $request ): \WP_REST_Res ); } +/** + * Whether a JSON-RPC method maps to a synchronous send turn. + * + * @param string $method JSON-RPC method. + */ +function agents_chat_jsonrpc_method_sends( string $method ): bool { + return in_array( $method, array( AGENTS_CHAT_JSONRPC_METHOD_SEND, AGENTS_CHAT_JSONRPC_METHOD_TASKS_SEND ), true ); +} + +/** + * Whether a JSON-RPC method maps to a streaming turn. + * + * @param string $method JSON-RPC method. + */ +function agents_chat_jsonrpc_method_streams( string $method ): bool { + return in_array( $method, array( AGENTS_CHAT_JSONRPC_METHOD_STREAM, AGENTS_CHAT_JSONRPC_METHOD_TASKS_SEND_SUBSCRIBE ), true ); +} + /** * Run one synchronous agents/chat turn. * @@ -280,9 +302,10 @@ function agents_chat_jsonrpc_stream( $rpc_id, array $input ): void { * * @param array $params JSON-RPC params (MessageSendParams). * @param string $agent Agent slug from the URL. + * @param array $body Full JSON-RPC request body. * @return array|\WP_Error */ -function agents_chat_jsonrpc_input_from_params( array $params, string $agent ) { +function agents_chat_jsonrpc_input_from_params( array $params, string $agent, array $body = array() ) { if ( '' === $agent ) { return new \WP_Error( 'agents_chat_jsonrpc_invalid_params', 'A non-empty agent id is required.' ); } @@ -296,9 +319,13 @@ function agents_chat_jsonrpc_input_from_params( array $params, string $agent ) { $session_id = \AgentsAPI\AI\agents_api_scalar_to_string( $params['sessionId'] ?? null ); $run_id = \AgentsAPI\AI\agents_api_scalar_to_string( $params['id'] ?? null ); - $client_context = array( - 'source' => 'jsonrpc', - 'client_name' => 'jsonrpc-chat', + $client_context = agents_chat_jsonrpc_client_context( $message ); + $client_context = array_merge( + $client_context, + array( + 'source' => 'jsonrpc', + 'client_name' => 'jsonrpc-chat', + ) ); if ( isset( $params['metadata'] ) && is_array( $params['metadata'] ) ) { $client_context['metadata'] = $params['metadata']; @@ -313,6 +340,10 @@ function agents_chat_jsonrpc_input_from_params( array $params, string $agent ) { 'client_context' => $client_context, ); + if ( array_key_exists( 'tokenStreaming', $body ) ) { + $input['token_streaming'] = (bool) $body['tokenStreaming']; + } + /** * Filter the canonical agents/chat input built by the JSON-RPC adapter. * @@ -326,6 +357,30 @@ function agents_chat_jsonrpc_input_from_params( array $params, string $agent ) { return is_array( $filtered ) ? \AgentsAPI\AI\agents_api_string_keyed_array( $filtered ) : $input; } +/** + * Extract client context from Agent Protocol data parts. + * + * @param array $message JSON-RPC Message. + * @return array + */ +function agents_chat_jsonrpc_client_context( array $message ): array { + $parts = isset( $message['parts'] ) && is_array( $message['parts'] ) ? $message['parts'] : array(); + $client_context = array(); + + foreach ( $parts as $part ) { + if ( ! is_array( $part ) || 'data' !== ( $part['type'] ?? null ) ) { + continue; + } + + $data = isset( $part['data'] ) && is_array( $part['data'] ) ? $part['data'] : array(); + if ( isset( $data['clientContext'] ) && is_array( $data['clientContext'] ) ) { + $client_context = array_merge( $client_context, \AgentsAPI\AI\agents_api_string_keyed_array( $data['clientContext'] ) ); + } + } + + return $client_context; +} + /** * Map canonical agents/chat output onto a JSON-RPC Task. * diff --git a/tests/agents-chat-jsonrpc-route-smoke.php b/tests/agents-chat-jsonrpc-route-smoke.php index 9f66beb..818c3f1 100644 --- a/tests/agents-chat-jsonrpc-route-smoke.php +++ b/tests/agents-chat-jsonrpc-route-smoke.php @@ -124,9 +124,13 @@ function wp_get_ability( string $name ) { use function AgentsAPI\AI\Channels\agents_chat_jsonrpc_task_from_output; use function AgentsAPI\AI\Channels\agents_chat_jsonrpc_delta_to_wire; use function AgentsAPI\AI\Channels\agents_chat_jsonrpc_delta_frame; +use function AgentsAPI\AI\Channels\agents_chat_jsonrpc_dispatch; use function AgentsAPI\AI\Channels\agents_chat_jsonrpc_result_frame; use function AgentsAPI\AI\Channels\agents_chat_jsonrpc_error_frame; use function AgentsAPI\AI\Channels\agents_chat_jsonrpc_extract_text; +use function AgentsAPI\AI\Channels\agents_chat_jsonrpc_client_context; +use function AgentsAPI\AI\Channels\agents_chat_jsonrpc_method_sends; +use function AgentsAPI\AI\Channels\agents_chat_jsonrpc_method_streams; use function AgentsAPI\AI\Channels\agents_chat_input_schema; use function AgentsAPI\AI\Channels\register_chat_stream_handler; @@ -161,19 +165,22 @@ function wp_get_ability( string $name ) { array( 'type' => 'text', 'text' => 'Hello ' ), array( 'type' => 'text', 'text' => 'world' ), array( 'type' => 'text', 'text' => 'SECRET', 'contentType' => 'context' ), + array( 'type' => 'data', 'data' => array( 'clientContext' => array( 'traceId' => 'trace-1' ) ) ), array( 'type' => 'file', 'file' => array( 'name' => 'a.png', 'mimeType' => 'image/png' ) ), ), ), - 'metadata' => array( 'locale' => 'es' ), + 'metadata' => array( 'locale' => 'es' ), ); -$input = agents_chat_jsonrpc_input_from_params( $params, 'support-agent' ); +$input = agents_chat_jsonrpc_input_from_params( $params, 'support-agent', array( 'tokenStreaming' => true ) ); agents_api_smoke_assert_equals( false, $input instanceof WP_Error, 'input mapping succeeds', $failures, $passes ); agents_api_smoke_assert_equals( 'support-agent', $input['agent'] ?? null, 'input carries agent slug from URL', $failures, $passes ); agents_api_smoke_assert_equals( 'Hello world', $input['message'] ?? null, 'input concatenates text parts and skips context parts', $failures, $passes ); agents_api_smoke_assert_equals( 'sess-9', $input['session_id'] ?? null, 'input carries sessionId', $failures, $passes ); agents_api_smoke_assert_equals( 'rpc-1', $input['run_id'] ?? null, 'input maps JSON-RPC id to run_id', $failures, $passes ); agents_api_smoke_assert_equals( 'jsonrpc', $input['client_context']['source'] ?? null, 'input marks jsonrpc source', $failures, $passes ); +agents_api_smoke_assert_equals( 'trace-1', $input['client_context']['traceId'] ?? null, 'input preserves camelCase clientContext', $failures, $passes ); +agents_api_smoke_assert_equals( true, $input['token_streaming'] ?? null, 'input maps top-level tokenStreaming to token_streaming', $failures, $passes ); $input_schema = agents_chat_input_schema(); $source_enum = $input_schema['properties']['client_context']['properties']['source']['enum'] ?? array(); agents_api_smoke_assert_equals( true, in_array( $input['client_context']['source'] ?? null, $source_enum, true ), 'input source is accepted by agents/chat schema', $failures, $passes ); @@ -182,6 +189,51 @@ function wp_get_ability( string $name ) { $empty = agents_chat_jsonrpc_input_from_params( array( 'message' => array( 'parts' => array() ) ), 'support-agent' ); agents_api_smoke_assert_equals( true, $empty instanceof WP_Error, 'input rejects empty message', $failures, $passes ); +$context = agents_chat_jsonrpc_client_context( + array( + 'parts' => array( + array( 'type' => 'data', 'data' => array( 'clientContext' => array( 'first' => 'a', 'shared' => 'old' ) ) ), + array( 'type' => 'data', 'data' => array( 'clientContext' => array( 'second' => 'b', 'shared' => 'new' ) ) ), + ), + ) +); +agents_api_smoke_assert_equals( array( 'first' => 'a', 'shared' => 'new', 'second' => 'b' ), $context, 'clientContext data parts merge in order', $failures, $passes ); + +$top_level_token_streaming = agents_chat_jsonrpc_input_from_params( $params, 'support-agent', array( 'tokenStreaming' => false ) ); +agents_api_smoke_assert_equals( false, $top_level_token_streaming['token_streaming'] ?? null, 'input preserves false top-level tokenStreaming', $failures, $passes ); + +// --- Legacy Agent Protocol method aliases ----------------------------------- +agents_api_smoke_assert_equals( true, agents_chat_jsonrpc_method_sends( 'message/send' ), 'message/send maps to sync send', $failures, $passes ); +agents_api_smoke_assert_equals( true, agents_chat_jsonrpc_method_sends( 'tasks/send' ), 'tasks/send maps to sync send', $failures, $passes ); +agents_api_smoke_assert_equals( true, agents_chat_jsonrpc_method_streams( 'message/stream' ), 'message/stream maps to stream', $failures, $passes ); +agents_api_smoke_assert_equals( true, agents_chat_jsonrpc_method_streams( 'tasks/sendSubscribe' ), 'tasks/sendSubscribe maps to stream', $failures, $passes ); + +$GLOBALS['__agents_api_smoke_abilities']['agents/chat'] = new class() { + public function execute( array $in ): array { + $GLOBALS['__agents_api_smoke_jsonrpc_last_input'] = $in; + return array( + 'session_id' => $in['session_id'] ?? '', + 'reply' => 'alias ok', + 'run_id' => $in['run_id'] ?? '', + 'completed' => true, + ); + } +}; +$alias_response = agents_chat_jsonrpc_dispatch( + new WP_REST_Request( + array( 'agent_id' => 'support-agent' ), + array( + 'jsonrpc' => '2.0', + 'id' => 'rpc-alias', + 'method' => 'tasks/send', + 'params' => array_merge( $params, array( 'id' => 'rpc-alias' ) ), + ) + ) +); +agents_api_smoke_assert_equals( 'rpc-alias', $alias_response->data['id'] ?? null, 'tasks/send dispatch echoes rpc id', $failures, $passes ); +agents_api_smoke_assert_equals( 'alias ok', $alias_response->data['result']['status']['message']['parts'][0]['text'] ?? null, 'tasks/send dispatch runs sync handler', $failures, $passes ); +agents_api_smoke_assert_equals( 'trace-1', $GLOBALS['__agents_api_smoke_jsonrpc_last_input']['client_context']['traceId'] ?? null, 'tasks/send dispatch preserves clientContext', $failures, $passes ); + // extract_text directly agents_api_smoke_assert_equals( 'ab', agents_chat_jsonrpc_extract_text( array( 'parts' => array( array( 'type' => 'text', 'text' => 'a' ), array( 'type' => 'text', 'text' => 'b' ) ) ) ), 'extract_text concatenates', $failures, $passes );