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 @@ -88,6 +88,7 @@
require_once AGENTS_API_PATH . 'src/Identity/class-wp-agent-identity-scope.php';
require_once AGENTS_API_PATH . 'src/Identity/class-wp-agent-materialized-identity.php';
require_once AGENTS_API_PATH . 'src/Identity/class-wp-agent-identity-store.php';
require_once AGENTS_API_PATH . 'src/Identity/class-wp-agent-identity-stores.php';
require_once AGENTS_API_PATH . 'src/Transcripts/class-wp-agent-conversation-store.php';
require_once AGENTS_API_PATH . 'src/Transcripts/class-wp-agent-principal-conversation-store.php';
require_once AGENTS_API_PATH . 'src/Transcripts/class-wp-agent-principal-conversation-session-reader.php';
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"php tests/approval-resolver-contract-smoke.php",
"php tests/pending-action-abilities-smoke.php",
"php tests/identity-smoke.php",
"php tests/identity-store-materialization-smoke.php",
"php tests/memory-metadata-contract-smoke.php",
"php tests/memory-store-resolver-smoke.php",
"php tests/ability-lifecycle-bridge-smoke.php",
Expand Down
34 changes: 34 additions & 0 deletions src/Identity/class-wp-agent-identity-stores.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php
/**
* Host-store discovery for materialized agent identities.
*
* @package AgentsAPI
*/

namespace AgentsAPI\Core\Identity;

defined( 'ABSPATH' ) || exit;

/**
* Resolves the host-provided materialized agent identity store.
*/
final class WP_Agent_Identity_Stores {

/**
* Resolve the host-provided identity store.
*
* Host plugins can pass a store directly in `$context['identity_store']`
* or provide one through the `wp_agent_identity_store` filter.
*
* @param array<string,mixed> $context Host-owned request context.
* @return WP_Agent_Identity_Store|null
*/
public static function get_store( array $context = array() ): ?WP_Agent_Identity_Store {
if ( isset( $context['identity_store'] ) && $context['identity_store'] instanceof WP_Agent_Identity_Store ) {
return $context['identity_store'];
}

$store = function_exists( 'apply_filters' ) ? apply_filters( 'wp_agent_identity_store', null, $context ) : null;
return $store instanceof WP_Agent_Identity_Store ? $store : null;
}
}
108 changes: 108 additions & 0 deletions src/Registry/register-agents.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,114 @@ function wp_unregister_agent( string $slug ): ?WP_Agent {
}
}

if ( ! function_exists( 'wp_get_agent_identity_store' ) ) {
/**
* Resolves the host-provided materialized identity store.
*
* Hosts can pass a store with `$context['identity_store']` or provide one
* through the `wp_agent_identity_store` filter. Agents API does not choose a
* concrete storage backend.
*
* @param array<string,mixed> $context Host-owned request context.
* @return \AgentsAPI\Core\Identity\WP_Agent_Identity_Store|null
*/
function wp_get_agent_identity_store( array $context = array() ): ?\AgentsAPI\Core\Identity\WP_Agent_Identity_Store {
return \AgentsAPI\Core\Identity\WP_Agent_Identity_Stores::get_store( $context );
}
}

if ( ! function_exists( 'wp_materialize_agent_identity' ) ) {
/**
* Materializes a registered agent definition through a host identity store.
*
* The identity scope is derived from the registered agent slug, owner user ID,
* and instance key. Callers may pass `owner_user_id` and `instance_key` in
* `$args`; otherwise owner defaults to the agent's resolver when present, then
* `0`, and instance defaults to `default`.
*
* @param string|WP_Agent $agent Agent slug or definition object.
* @param \AgentsAPI\Core\Identity\WP_Agent_Identity_Store|null $store Store, or null to use the resolver.
* @param array<string,mixed> $args Host-owned materialization options and context.
* @return \AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity|null Materialized identity, or null without a registered agent/store.
*/
function wp_materialize_agent_identity( $agent, ?\AgentsAPI\Core\Identity\WP_Agent_Identity_Store $store = null, array $args = array() ): ?\AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity {
$agent = $agent instanceof WP_Agent ? $agent : wp_get_agent( (string) $agent );
if ( ! $agent instanceof WP_Agent ) {
return null;
}

$store = $store instanceof \AgentsAPI\Core\Identity\WP_Agent_Identity_Store ? $store : wp_get_agent_identity_store( $args );
if ( ! $store instanceof \AgentsAPI\Core\Identity\WP_Agent_Identity_Store ) {
return null;
}

$owner_user_id = wp_resolve_agent_identity_owner_user_id( $agent, $args );
$instance_key = isset( $args['instance_key'] ) && is_scalar( $args['instance_key'] ) ? (string) $args['instance_key'] : 'default';
$scope = new \AgentsAPI\Core\Identity\WP_Agent_Identity_Scope( $agent->get_slug(), $owner_user_id, $instance_key );
$meta = $agent->get_meta();
if ( isset( $args['meta'] ) && is_array( $args['meta'] ) ) {
foreach ( $args['meta'] as $key => $value ) {
if ( is_string( $key ) ) {
$meta[ $key ] = $value;
}
}
}

return $store->materialize( $scope, $agent->get_default_config(), $meta );
}
}

if ( ! function_exists( 'wp_materialize_registered_agent_identities' ) ) {
/**
* Materializes all currently registered agents through a host identity store.
*
* @param \AgentsAPI\Core\Identity\WP_Agent_Identity_Store|null $store Store, or null to use the resolver.
* @param array<string,mixed> $args Host-owned materialization options and context.
* @return array<string,\AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity> Identities keyed by registered agent slug.
*/
function wp_materialize_registered_agent_identities( ?\AgentsAPI\Core\Identity\WP_Agent_Identity_Store $store = null, array $args = array() ): array {
$store = $store instanceof \AgentsAPI\Core\Identity\WP_Agent_Identity_Store ? $store : wp_get_agent_identity_store( $args );
if ( ! $store instanceof \AgentsAPI\Core\Identity\WP_Agent_Identity_Store ) {
return array();
}

$identities = array();
foreach ( wp_get_agents() as $agent ) {
$identity = wp_materialize_agent_identity( $agent, $store, $args );
if ( $identity instanceof \AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity ) {
$identities[ $agent->get_slug() ] = $identity;
}
}

return $identities;
}
}

if ( ! function_exists( 'wp_resolve_agent_identity_owner_user_id' ) ) {
/**
* Resolves the owner user ID for generic identity materialization.
*
* @param WP_Agent $agent Registered agent definition.
* @param array<string,mixed> $args Materialization options.
* @return int Non-negative owner user ID. Zero means shared/no owner.
*/
function wp_resolve_agent_identity_owner_user_id( WP_Agent $agent, array $args = array() ): int {
if ( isset( $args['owner_user_id'] ) && is_numeric( $args['owner_user_id'] ) ) {
return max( 0, (int) $args['owner_user_id'] );
}

$owner_resolver = $agent->get_owner_resolver();
if ( is_callable( $owner_resolver ) ) {
$owner_user_id = call_user_func( $owner_resolver );
if ( is_numeric( $owner_user_id ) ) {
return max( 0, (int) $owner_user_id );
}
}

return 0;
}
}

if ( ! function_exists( 'wp_materialize_registered_agents' ) ) {
/**
* Materializes registered agents through a host-provided adapter.
Expand Down
134 changes: 134 additions & 0 deletions tests/identity-store-materialization-smoke.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php
/**
* Pure-PHP smoke test for registered-agent identity store materialization.
*
* Run with: php tests/identity-store-materialization-smoke.php
*
* @package AgentsAPI\Tests
*/

if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}

$failures = array();
$passes = 0;

echo "agents-api-identity-store-materialization-smoke\n";

require_once __DIR__ . '/agents-api-smoke-helpers.php';
agents_api_smoke_require_module();

final class Agents_API_Test_Identity_Store implements AgentsAPI\Core\Identity\WP_Agent_Identity_Store {
/** @var array<string,AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity> */
private array $identities = array();

private int $next_id = 1;

public function resolve( AgentsAPI\Core\Identity\WP_Agent_Identity_Scope $scope ): ?AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity {
return $this->identities[ $scope->key() ] ?? null;
}

public function get( int $identity_id ): ?AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity {
foreach ( $this->identities as $identity ) {
if ( $identity->id === $identity_id ) {
return $identity;
}
}

return null;
}

public function materialize( AgentsAPI\Core\Identity\WP_Agent_Identity_Scope $scope, array $default_config = array(), array $meta = array() ): AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity {
$normalized_scope = $scope->normalize();
$key = $normalized_scope->key();

if ( isset( $this->identities[ $key ] ) ) {
return $this->identities[ $key ];
}

$identity = new AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity( $this->next_id++, $normalized_scope, $default_config, $meta, 10, 10 );
$this->identities[ $key ] = $identity;

return $identity;
}

public function update( AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity $identity ): AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity {
$this->identities[ $identity->scope->key() ] = $identity;
return $identity;
}

public function delete( AgentsAPI\Core\Identity\WP_Agent_Identity_Scope $scope ): bool {
unset( $this->identities[ $scope->key() ] );
return true;
}
}

$store = new Agents_API_Test_Identity_Store();

add_action(
'wp_agents_api_init',
static function (): void {
wp_register_agent(
'kitchen-agent',
array(
'label' => 'Kitchen Agent',
'default_config' => array( 'model' => 'gpt-test' ),
'owner_resolver' => static fn() => 7,
'meta' => array(
'source_plugin' => 'example/example.php',
'source_type' => 'bundled-agent',
'source_package' => 'chef-pack',
'source_version' => '1.0.0',
),
)
);

wp_register_agent( 'pantry-agent', array( 'label' => 'Pantry Agent' ) );
}
);

do_action( 'init' );

echo "\n[1] Identity store can be provided directly through context:\n";
agents_api_smoke_assert_equals( true, wp_get_agent_identity_store( array( 'identity_store' => $store ) ) === $store, 'context identity store is resolved', $failures, $passes );

echo "\n[2] A registered agent materializes through the identity store:\n";
$identity = wp_materialize_agent_identity( 'kitchen-agent', $store, array( 'instance_key' => 'Site:42' ) );
agents_api_smoke_assert_equals( true, $identity instanceof AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity, 'single registered agent materializes', $failures, $passes );
agents_api_smoke_assert_equals( 'kitchen-agent', $identity instanceof AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity ? $identity->scope->normalize()->agent_slug : null, 'identity scope uses registered slug', $failures, $passes );
agents_api_smoke_assert_equals( 7, $identity instanceof AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity ? $identity->scope->normalize()->owner_user_id : null, 'owner resolver supplies owner user ID', $failures, $passes );
agents_api_smoke_assert_equals( 'site:42', $identity instanceof AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity ? $identity->scope->normalize()->instance_key : null, 'caller instance key is normalized', $failures, $passes );
agents_api_smoke_assert_equals( array( 'model' => 'gpt-test' ), $identity instanceof AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity ? $identity->config : null, 'agent default config is passed to first materialization', $failures, $passes );
agents_api_smoke_assert_equals( 'example/example.php', $identity instanceof AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity ? ( $identity->meta['source_plugin'] ?? null ) : null, 'agent source metadata is passed to first materialization', $failures, $passes );

echo "\n[3] Registered agent materialization is idempotent for the same scope:\n";
$second_identity = wp_materialize_agent_identity( 'kitchen-agent', $store, array( 'instance_key' => 'Site:42' ) );
agents_api_smoke_assert_equals( $identity instanceof AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity ? $identity->id : null, $second_identity instanceof AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity ? $second_identity->id : null, 'same identity store row is reused', $failures, $passes );

echo "\n[4] Missing stores and unknown agents no-op cleanly:\n";
agents_api_smoke_assert_equals( array(), wp_materialize_registered_agent_identities(), 'no store means no materialization', $failures, $passes );
agents_api_smoke_assert_equals( null, wp_materialize_agent_identity( 'missing-agent', $store ), 'unknown agent returns null', $failures, $passes );

echo "\n[5] All registered agents can materialize through a filter-provided store:\n";
add_filter(
'wp_agent_identity_store',
static function () use ( $store ): AgentsAPI\Core\Identity\WP_Agent_Identity_Store {
return $store;
}
);

$identities = wp_materialize_registered_agent_identities(
null,
array(
'owner_user_id' => 13,
'instance_key' => 'Site:42',
'meta' => array( 'materialized_by' => 'smoke' ),
)
);
agents_api_smoke_assert_equals( array( 'kitchen-agent', 'pantry-agent' ), array_keys( $identities ), 'all registered agents materialize via filter-provided store', $failures, $passes );
agents_api_smoke_assert_equals( 13, $identities['pantry-agent']->scope->normalize()->owner_user_id ?? null, 'explicit owner_user_id overrides defaults', $failures, $passes );
agents_api_smoke_assert_equals( 'smoke', $identities['pantry-agent']->meta['materialized_by'] ?? null, 'caller metadata is merged into materialization metadata', $failures, $passes );
agents_api_smoke_assert_equals( false, class_exists( 'DataMachine_Agent_Store', false ), 'materialization lifecycle does not load Data Machine classes', $failures, $passes );

agents_api_smoke_finish( 'Agents API identity store materialization', $failures, $passes );
Loading