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
84 changes: 81 additions & 3 deletions includes/class-openclawp-agenttic-bridge.php
Original file line number Diff line number Diff line change
Expand Up @@ -169,14 +169,20 @@ public static function handle( WP_REST_Request $request ): WP_REST_Response {
$session_id = isset( $params['sessionId'] ) && is_string( $params['sessionId'] ) ? $params['sessionId'] : null;
$task_id = isset( $params['id'] ) && is_string( $params['id'] ) ? $params['id'] : self::generate_task_id();

// Agenttic sends dynamic client context as a data part on the message.
// Preserve it as canonical agents/chat client_context so runtimes can
// expose it to model prompts and tool policies.
$client_context = self::client_context_from_message( $message );

// When the request carries agents-api caller-chain headers, this is an
// agent-to-agent delegation. Parse them fail-closed (malformed headers
// are a hard error, matching the substrate's request-edge contract) and
// tag the turn so `agents/chat` records it as a peer-agent call (#180).
$client_context = self::peer_client_context( $request );
if ( is_wp_error( $client_context ) ) {
return self::error_response( $rpc_id, self::INVALID_PARAMS, $client_context->get_error_message() );
$peer_context = self::peer_client_context( $request );
if ( is_wp_error( $peer_context ) ) {
return self::error_response( $rpc_id, self::INVALID_PARAMS, $peer_context->get_error_message() );
}
$client_context = self::merge_client_context( $client_context, $peer_context );

if ( ! function_exists( 'wp_get_ability' ) ) {
return self::error_response( $rpc_id, self::INTERNAL_ERROR, 'Abilities API is not loaded.' );
Expand Down Expand Up @@ -379,6 +385,78 @@ private static function extract_text( array $message ): string {
return '';
}

/**
* Extract Agenttic's dynamic clientContext data part from an A2A message.
*
* @param array<string,mixed> $message A2A message.
* @return array<string,mixed>
*/
public static function client_context_from_message( array $message ): array {
$parts = isset( $message['parts'] ) && is_array( $message['parts'] ) ? $message['parts'] : array();
$context = array();

foreach ( $parts as $part ) {
if ( ! is_array( $part ) || 'data' !== ( $part['type'] ?? '' ) ) {
continue;
}
$data = isset( $part['data'] ) && is_array( $part['data'] ) ? $part['data'] : array();
foreach ( array( 'clientContext', 'client_context' ) as $key ) {
if ( ! isset( $data[ $key ] ) || ! is_array( $data[ $key ] ) || array_is_list( $data[ $key ] ) ) {
continue;
}
$context = self::merge_client_context( $context, self::string_keyed_context( $data[ $key ] ) );
}
}

if ( empty( $context ) ) {
return array();
}

return array_merge(
array( 'client_name' => 'agenttic-client' ),
$context,
array( 'source' => 'agenttic' )
);
}

/**
* Merge client context maps while preserving later, trusted values.
*
* @param array<string,mixed> ...$contexts Context maps.
* @return array<string,mixed>
*/
private static function merge_client_context( array ...$contexts ): array {
$merged = array();
foreach ( $contexts as $context ) {
if ( empty( $context ) ) {
continue;
}
$merged = array_replace_recursive( $merged, self::string_keyed_context( $context ) );
}
return $merged;
}

/**
* Keep only string-keyed object fields recursively.
*
* @param array<mixed> $context Raw context map.
* @return array<string,mixed>
*/
private static function string_keyed_context( array $context ): array {
$out = array();
foreach ( $context as $key => $value ) {
if ( ! is_string( $key ) || '' === $key ) {
continue;
}
if ( is_array( $value ) && ! array_is_list( $value ) ) {
$out[ $key ] = self::string_keyed_context( $value );
continue;
}
$out[ $key ] = $value;
}
return $out;
}

/**
* Build a JSON-RPC success envelope. Always HTTP 200 — JSON-RPC carries
* its own status semantics.
Expand Down
97 changes: 95 additions & 2 deletions includes/class-openclawp-runner.php
Original file line number Diff line number Diff line change
Expand Up @@ -187,13 +187,16 @@ public static function run_turn( string $agent_slug, string $message, ?string $s
'transcript_session_id' => $session_id,
'transcript_lock_ttl' => 300,
);
$loop_context = self::tool_context_from_runtime_context( $runtime_context );
if ( ! empty( $loop_context['client_context'] ) || count( $loop_context ) > 1 ) {
$loop_options['context'] = $loop_context;
}
if ( null !== $tool_executor ) {
// Canonical's loop defaults `should_continue` to continue-always when
// `tool_executor` + `tool_declarations` are both present (agents-api
// PR #97), so we don't need to override it.
$loop_options['tool_executor'] = $tool_executor;
$loop_options['tool_declarations'] = $tools['declarations'];
$loop_options['context'] = self::tool_context_from_runtime_context( $runtime_context );
}

$result = WP_Agent_Conversation_Loop::run( $messages, $turn_runner, $loop_options );
Expand Down Expand Up @@ -308,6 +311,95 @@ public static function message_with_attachments( string $message, array $runtime
return rtrim( $message ) . "\n\nAttachments:\n" . implode( "\n", $lines );
}

/**
* Add an ephemeral model-visible client context block to the latest user turn.
*
* Agenttic's `contextProvider` sends dynamic per-turn context outside the
* text part. The runner keeps that context out of the stored transcript, but
* injects it into the provider prompt so an agent can follow instructions like
* "if clientContext.foo is present, use it".
*
* @param array<int,array<string,mixed>> $messages Current transcript.
* @param array<string,mixed> $turn_context Loop turn context.
* @return array<int,array<string,mixed>>
*/
public static function messages_with_client_context( array $messages, array $turn_context ): array {
$client_context = isset( $turn_context['client_context'] ) && is_array( $turn_context['client_context'] )
? $turn_context['client_context']
: array();
if ( empty( $client_context ) ) {
return $messages;
}

$block = self::client_context_prompt_block( $client_context, $turn_context );
if ( '' === $block ) {
return $messages;
}

for ( $i = count( $messages ) - 1; $i >= 0; --$i ) {
if ( 'user' !== ( $messages[ $i ]['role'] ?? '' ) ) {
continue;
}

$content = $messages[ $i ]['content'] ?? '';
if ( is_string( $content ) ) {
$messages[ $i ]['content'] = rtrim( $content ) . "\n\n" . $block;
return $messages;
}

if ( is_array( $content ) ) {
$content[] = array(
'type' => 'text',
'text' => $block,
);
$messages[ $i ]['content'] = $content;
return $messages;
}
}

return $messages;
}

/**
* Build a compact, filterable prompt block for dynamic client context.
*
* @param array<string,mixed> $client_context Raw client context.
* @param array<string,mixed> $turn_context Loop turn context.
* @return string
*/
private static function client_context_prompt_block( array $client_context, array $turn_context ): string {
/**
* Filters dynamic client context before it is exposed to the model.
*
* Return an empty array to hide client context from the model.
*
* @param array<string,mixed> $client_context Client context for this turn.
* @param array<string,mixed> $turn_context Loop turn context.
*/
$client_context = (array) apply_filters( 'openclawp_model_client_context', $client_context, $turn_context );
if ( empty( $client_context ) ) {
return '';
}

$json = wp_json_encode( $client_context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
if ( ! is_string( $json ) || '' === $json ) {
return '';
}

$block = "[Client context for this turn]\nclientContext=" . $json . "\n[/Client context]";

/**
* Filters the rendered client-context prompt block.
*
* @param string $block Client-context prompt block.
* @param array<string,mixed> $client_context Client context for this turn.
* @param array<string,mixed> $turn_context Loop turn context.
*/
$block = (string) apply_filters( 'openclawp_model_client_context_block', $block, $client_context, $turn_context );

return trim( $block );
}

/**
* Build the closure passed to the loop. Filterable so adopters can swap
* providers (e.g. Menta-flavored Gemini-OAuth) without touching this file.
Expand Down Expand Up @@ -346,7 +438,8 @@ private static function build_turn_runner( WP_Agent $agent, array $session, stri
);
}

$ai_messages = OpenclaWP_Message_Adapter::to_ai_client_messages( $messages );
$messages_for_model = self::messages_with_client_context( $messages, $context );
$ai_messages = OpenclaWP_Message_Adapter::to_ai_client_messages( $messages_for_model );

$builder = wp_ai_client_prompt( $ai_messages );
if ( $mediation_enabled ) {
Expand Down
86 changes: 86 additions & 0 deletions tests/unit/AgentticClientContextTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php
/**
* Unit tests for Agenttic client-context forwarding.
*
* @package OpenclaWP\Tests
*/

declare( strict_types=1 );

namespace OpenclaWP\Tests\Unit;

use OpenclaWP_Agenttic_Bridge;
use OpenclaWP_Runner;
use PHPUnit\Framework\TestCase;

/**
* @covers OpenclaWP_Agenttic_Bridge
* @covers OpenclaWP_Runner
*/
final class AgentticClientContextTest extends TestCase {

public function test_bridge_extracts_agenttic_client_context_data_part(): void {
$message = array(
'role' => 'user',
'parts' => array(
array(
'type' => 'text',
'text' => 'Sí, borrar',
),
array(
'type' => 'data',
'data' => array(
'clientContext' => array(
'carpeta_pending_confirmation' => array(
'type' => 'delete-potreros',
'ids' => array( 1312, 1313 ),
),
),
),
),
),
);

$context = OpenclaWP_Agenttic_Bridge::client_context_from_message( $message );

$this->assertSame( 'agenttic', $context['source'] );
$this->assertSame( 'agenttic-client', $context['client_name'] );
$this->assertSame(
array(
'type' => 'delete-potreros',
'ids' => array( 1312, 1313 ),
),
$context['carpeta_pending_confirmation']
);
}

public function test_runner_injects_client_context_into_model_messages_only(): void {
$messages = array(
array(
'role' => 'assistant',
'content' => '¿Borro estos 2 potreros?',
),
array(
'role' => 'user',
'content' => 'Sí, borrar',
),
);

$for_model = OpenclaWP_Runner::messages_with_client_context(
$messages,
array(
'client_context' => array(
'carpeta_pending_confirmation' => array(
'type' => 'delete-potreros',
'ids' => array( 1312, 1313 ),
),
),
)
);

$this->assertSame( 'Sí, borrar', $messages[1]['content'] );
$this->assertStringContainsString( 'Sí, borrar', $for_model[1]['content'] );
$this->assertStringContainsString( '[Client context for this turn]', $for_model[1]['content'] );
$this->assertStringContainsString( '"carpeta_pending_confirmation":{"type":"delete-potreros","ids":[1312,1313]}', $for_model[1]['content'] );
}
}
Loading