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
1 change: 1 addition & 0 deletions agents-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
65 changes: 65 additions & 0 deletions src/Channels/register-agents-chat-ability.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -767,6 +770,68 @@ function agents_chat_output_schema(): array {
);
}

/**
* Canonical terminal or suspended chat status.
*
* @return array<string,mixed>
*/
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<string,mixed>
*/
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<string,mixed>
*/
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.
*
Expand Down
73 changes: 66 additions & 7 deletions src/Channels/register-default-agents-chat-handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -201,20 +202,31 @@ 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(
'agent_config' => $config,
'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,
)
);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string,mixed> $config Agent default config.
* @param array<mixed> $overlays Server-provided declaration overlays.
Expand Down Expand Up @@ -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' );
Expand All @@ -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 );
Expand Down Expand Up @@ -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<string,mixed> $input Canonical chat input.
* @return array<string,mixed>
*/
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.
*
Expand Down Expand Up @@ -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;
}

/**
Expand Down
29 changes: 22 additions & 7 deletions src/Runtime/register-runtime-tool-lifecycle-abilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $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;
Expand All @@ -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<string, mixed> $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<string, mixed> $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.
*
Expand Down
61 changes: 61 additions & 0 deletions src/Tools/class-wp-agent-default-chat-tool-executor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php
/**
* Default chat tool executor router.
*
* @package AgentsAPI
*/

namespace AgentsAPI\AI\Tools;

use AgentsAPI\AI\WP_Agent_Runtime_Tool_Request;

defined( 'ABSPATH' ) || exit;

/**
* Routes client tools to the runtime-tool continuation boundary.
*/
class WP_Agent_Default_Chat_Tool_Executor implements WP_Agent_Tool_Executor {

/** @var WP_Agent_Tool_Executor */
private WP_Agent_Tool_Executor $host_executor;

public function __construct( WP_Agent_Tool_Executor $host_executor ) {
$this->host_executor = $host_executor;
}

/**
* Suspend client-owned tools; preserve host executor behavior unchanged.
*
* @param array<mixed> $tool_call Normalized tool call.
* @param array<mixed> $tool_definition Selected declaration.
* @param array<mixed> $context Runtime context.
* @return array<mixed>
*/
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,
);
}
}
6 changes: 6 additions & 0 deletions tests/agents-chat-ability-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading