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 @@ -60,6 +60,7 @@
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/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-conversation-sessions.php';
require_once AGENTS_API_PATH . 'src/Transcripts/class-wp-agent-conversation-lock.php';
require_once AGENTS_API_PATH . 'src/Transcripts/class-wp-agent-null-conversation-lock.php';
Expand Down
70 changes: 66 additions & 4 deletions src/Runtime/class-wp-agent-execution-principal.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ final class WP_Agent_Execution_Principal {
public const AUTH_SOURCE_AUDIENCE = 'audience';
public const AUTH_SOURCE_SYSTEM = 'system';

public const OWNER_TYPE_USER = 'user';
public const OWNER_TYPE_AUDIENCE = 'audience';
public const OWNER_TYPE_TOKEN = 'token';
public const OWNER_TYPE_SYSTEM = 'system';

public const REQUEST_CONTEXT_REST = 'rest';
public const REQUEST_CONTEXT_CLI = 'cli';
public const REQUEST_CONTEXT_CRON = 'cron';
Expand All @@ -43,6 +48,8 @@ final class WP_Agent_Execution_Principal {
* @param \WP_Agent_Caller_Context|null $caller_context Optional cross-site caller context claims.
* @param string|null $audience_id Optional non-user audience/principal identifier.
* @param array<string,mixed> $audience_claims Optional host-owned audience claims.
* @param string|null $owner_type Optional canonical transcript owner type.
* @param string|null $owner_key Optional opaque transcript owner key scoped to the owner type.
*/
public function __construct(
public readonly int $acting_user_id,
Expand All @@ -57,6 +64,8 @@ public function __construct(
public readonly ?\WP_Agent_Caller_Context $caller_context = null,
public readonly ?string $audience_id = null,
public readonly array $audience_claims = array(),
public readonly ?string $owner_type = null,
public readonly ?string $owner_key = null,
) {
if ( $this->acting_user_id < 0 ) {
throw self::invalid( 'acting_user_id', 'must be zero or a positive integer' );
Expand Down Expand Up @@ -89,6 +98,18 @@ public function __construct(
if ( false === self::jsonEncode( $this->audience_claims ) ) {
throw self::invalid( 'audience_claims', 'must be JSON serializable' );
}

if ( ( null === $this->owner_type ) !== ( null === $this->owner_key ) ) {
throw self::invalid( 'owner', 'type and key must both be present or both be null' );
}

if ( null !== $this->owner_type && '' === trim( $this->owner_type ) ) {
throw self::invalid( 'owner_type', 'must be null or a non-empty string' );
}

if ( null !== $this->owner_key && '' === trim( $this->owner_key ) ) {
throw self::invalid( 'owner_key', 'must be null or a non-empty string' );
}
}

/**
Expand Down Expand Up @@ -158,8 +179,8 @@ public static function agent_token( int $acting_user_id, string $effective_agent
* @param array $audience_claims Host-owned audience claims.
* @return self
*/
public static function audience( string $audience_id, string $effective_agent_id, string $request_context = self::REQUEST_CONTEXT_REST, array $request_metadata = array(), ?string $workspace_id = null, ?string $client_id = null, array $audience_claims = array() ): self {
return new self( 0, $effective_agent_id, self::AUTH_SOURCE_AUDIENCE, $request_context, null, $request_metadata, $workspace_id, $client_id, null, null, $audience_id, $audience_claims );
public static function audience( string $audience_id, string $effective_agent_id, string $request_context = self::REQUEST_CONTEXT_REST, array $request_metadata = array(), ?string $workspace_id = null, ?string $client_id = null, array $audience_claims = array(), ?string $owner_key = null ): self {
return new self( 0, $effective_agent_id, self::AUTH_SOURCE_AUDIENCE, $request_context, null, $request_metadata, $workspace_id, $client_id, null, null, $audience_id, $audience_claims, null !== $owner_key ? self::OWNER_TYPE_AUDIENCE : null, $owner_key );
}

/**
Expand Down Expand Up @@ -199,7 +220,9 @@ public static function from_array( array $principal ): self {
$capability_ceiling,
$caller_context,
array_key_exists( 'audience_id', $principal ) && null !== $principal['audience_id'] ? (string) $principal['audience_id'] : null,
isset( $principal['audience_claims'] ) && is_array( $principal['audience_claims'] ) ? $principal['audience_claims'] : array()
isset( $principal['audience_claims'] ) && is_array( $principal['audience_claims'] ) ? $principal['audience_claims'] : array(),
array_key_exists( 'owner_type', $principal ) && null !== $principal['owner_type'] ? (string) $principal['owner_type'] : null,
array_key_exists( 'owner_key', $principal ) && null !== $principal['owner_key'] ? (string) $principal['owner_key'] : null
);
}

Expand All @@ -222,9 +245,46 @@ public function to_array(): array {
'caller_context' => $this->caller_context instanceof \WP_Agent_Caller_Context ? $this->caller_context->to_array() : null,
'audience_id' => $this->audience_id,
'audience_claims' => $this->audience_claims,
'owner_type' => $this->owner_type,
'owner_key' => $this->owner_key,
);
}

/**
* Return the canonical transcript owner for this principal.
*
* Runtime authorization and transcript ownership are intentionally separate.
* User principals can safely derive ownership from the WordPress user ID. Non-user
* principals must provide an opaque owner key resolved by the host, such as a
* browser-session key; audience access alone is not a transcript owner.
*
* @return array{type:string,key:string}|null Principal owner, or null when this principal is not transcript-ownable.
*/
public function conversation_owner(): ?array {
if ( null !== $this->owner_type && null !== $this->owner_key ) {
return array(
'type' => $this->owner_type,
'key' => $this->owner_key,
);
}

if ( $this->acting_user_id > 0 && self::AUTH_SOURCE_AGENT_TOKEN !== $this->auth_source ) {
return array(
'type' => self::OWNER_TYPE_USER,
'key' => (string) $this->acting_user_id,
);
}

if ( self::AUTH_SOURCE_AGENT_TOKEN === $this->auth_source && null !== $this->token_id ) {
return array(
'type' => self::OWNER_TYPE_TOKEN,
'key' => (string) $this->token_id,
);
}

return null;
}

/**
* Whether this principal represents a host-resolved non-user audience.
*/
Expand All @@ -251,7 +311,9 @@ public function with_request_metadata( array $request_metadata ): self {
$this->capability_ceiling,
$this->caller_context,
$this->audience_id,
$this->audience_claims
$this->audience_claims,
$this->owner_type,
$this->owner_key
);
}

Expand Down
60 changes: 60 additions & 0 deletions src/Transcripts/class-wp-agent-principal-conversation-store.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php
/**
* Principal-owned agent conversation transcript persistence contract.
*
* @package AgentsAPI
*/

namespace AgentsAPI\Core\Database\Chat;

use AgentsAPI\Core\Workspace\WP_Agent_Workspace_Scope;

defined( 'ABSPATH' ) || exit;

/**
* Optional principal-owner-aware transcript persistence contract.
*
* Implement this in addition to WP_Agent_Conversation_Store when a backend can
* persist sessions for non-user principals. `$owner` is the canonical shape
* returned by WP_Agent_Execution_Principal::conversation_owner():
* `array( 'type' => 'user|audience|token|system', 'key' => '<opaque stable key>' )`.
*
* Legacy WP_Agent_Conversation_Store implementations remain valid for user-owned
* sessions through the int user ID methods above.
*/
interface WP_Agent_Principal_Conversation_Store extends WP_Agent_Conversation_Store {

/**
* Create a new conversation transcript session for a canonical principal owner.
*
* @param WP_Agent_Workspace_Scope $workspace Workspace owning the session.
* @param array{type:string,key:string} $owner Canonical principal owner.
* @param string $agent_slug Registered agent slug, or empty string for agent-less sessions.
* @param array $metadata Arbitrary session metadata (JSON-serializable).
* @param string $context Execution mode ('chat', 'pipeline', 'system').
* @return string Session ID (UUIDv4), or empty string on failure.
*/
public function create_session_for_owner( WP_Agent_Workspace_Scope $workspace, array $owner, string $agent_slug = '', array $metadata = array(), string $context = 'chat' ): string;

/**
* List transcript sessions for one workspace/principal-owner pair.
*
* @param WP_Agent_Workspace_Scope $workspace Workspace owning the sessions.
* @param array{type:string,key:string} $owner Canonical principal owner.
* @param array $args Optional host-supported filters/pagination.
* @return array<int,array<string,mixed>> Session rows.
*/
public function list_sessions_for_owner( WP_Agent_Workspace_Scope $workspace, array $owner, array $args = array() ): array;

/**
* Find a recent pending session for principal-owner scoped deduplication.
*
* @param WP_Agent_Workspace_Scope $workspace Workspace owning the session.
* @param array{type:string,key:string} $owner Canonical principal owner.
* @param int $seconds Lookback window.
* @param string $context Context filter.
* @param int|null $token_id Optional token ID for token-scoped dedup.
* @return array|null Session data or null if none.
*/
public function get_recent_pending_session_for_owner( WP_Agent_Workspace_Scope $workspace, array $owner, int $seconds = 600, string $context = 'chat', ?int $token_id = null ): ?array;
}
71 changes: 66 additions & 5 deletions src/Transcripts/register-agents-conversation-session-abilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,10 @@ function agents_list_conversation_sessions( array $input ) {
$args['context'] = (string) $input['context'];
}

$sessions = $context['store']->list_sessions( $workspace, $context['principal']->acting_user_id, $args );
$sessions = agents_conversation_sessions_list_for_owner( $context['store'], $workspace, $context['owner'], $args );
if ( is_wp_error( $sessions ) ) {
return $sessions;
}

return array(
'sessions' => array_map( __NAMESPACE__ . '\\agents_conversation_session_summary', $sessions ),
Expand Down Expand Up @@ -189,7 +192,10 @@ function agents_create_conversation_session( array $input ) {
$metadata = isset( $input['metadata'] ) && is_array( $input['metadata'] ) ? $input['metadata'] : array();
$agent_slug = isset( $input['agent'] ) ? (string) $input['agent'] : $context['principal']->effective_agent_id;
$mode = isset( $input['context'] ) ? (string) $input['context'] : WP_Agent_Execution_Principal::REQUEST_CONTEXT_CHAT;
$session_id = $context['store']->create_session( $workspace, $context['principal']->acting_user_id, $agent_slug, $metadata, $mode );
$session_id = agents_conversation_sessions_create_for_owner( $context['store'], $workspace, $context['owner'], $agent_slug, $metadata, $mode );
if ( is_wp_error( $session_id ) ) {
return $session_id;
}

if ( '' === $session_id ) {
return new \WP_Error( 'agents_conversation_session_create_failed', 'The conversation session store did not create a session.' );
Expand Down Expand Up @@ -249,24 +255,66 @@ function agents_conversation_sessions_permission( array $input ): bool {
return (bool) apply_filters( 'agents_conversation_sessions_permission', $allowed, $input );
}

/** @return array{store:WP_Agent_Conversation_Store,principal:WP_Agent_Execution_Principal}|\WP_Error */
/** @return array{store:WP_Agent_Conversation_Store,principal:WP_Agent_Execution_Principal,owner:array{type:string,key:string}}|\WP_Error */
function agents_conversation_sessions_context( array $input ) {
$principal = agents_conversation_sessions_principal( $input );
if ( ! $principal instanceof WP_Agent_Execution_Principal ) {
return new \WP_Error( 'agents_conversation_session_unauthenticated', 'A conversation session principal could not be resolved.' );
}

$owner = $principal->conversation_owner();
if ( null === $owner ) {
return new \WP_Error( 'agents_conversation_session_owner_required', 'The current principal does not provide a conversation session owner key.' );
}

$store = WP_Agent_Conversation_Sessions::get_store( array( 'principal' => $principal ) + $input );
if ( ! $store instanceof WP_Agent_Conversation_Store ) {
return new \WP_Error( 'agents_conversation_session_no_store', 'No WP_Agent_Conversation_Store is registered. Provide one with the wp_agent_conversation_store filter.' );
}

if ( ! $store instanceof WP_Agent_Principal_Conversation_Store && WP_Agent_Execution_Principal::OWNER_TYPE_USER !== $owner['type'] ) {
return new \WP_Error( 'agents_conversation_session_principal_store_required', 'The registered conversation session store does not support non-user principal owners.' );
}

return array(
'store' => $store,
'principal' => $principal,
'owner' => $owner,
);
}

/**
* @param array{type:string,key:string} $owner Canonical principal owner.
* @return string|\WP_Error
*/
function agents_conversation_sessions_create_for_owner( WP_Agent_Conversation_Store $store, WP_Agent_Workspace_Scope $workspace, array $owner, string $agent_slug = '', array $metadata = array(), string $context = 'chat' ) {
if ( $store instanceof WP_Agent_Principal_Conversation_Store ) {
return $store->create_session_for_owner( $workspace, $owner, $agent_slug, $metadata, $context );
}

if ( WP_Agent_Execution_Principal::OWNER_TYPE_USER !== $owner['type'] ) {
return new \WP_Error( 'agents_conversation_session_principal_store_required', 'The registered conversation session store does not support non-user principal owners.' );
}

return $store->create_session( $workspace, (int) $owner['key'], $agent_slug, $metadata, $context );
}

/**
* @param array{type:string,key:string} $owner Canonical principal owner.
* @return array<int,array<string,mixed>>|\WP_Error
*/
function agents_conversation_sessions_list_for_owner( WP_Agent_Conversation_Store $store, WP_Agent_Workspace_Scope $workspace, array $owner, array $args = array() ) {
if ( $store instanceof WP_Agent_Principal_Conversation_Store ) {
return $store->list_sessions_for_owner( $workspace, $owner, $args );
}

if ( WP_Agent_Execution_Principal::OWNER_TYPE_USER !== $owner['type'] ) {
return new \WP_Error( 'agents_conversation_session_principal_store_required', 'The registered conversation session store does not support non-user principal owners.' );
}

return $store->list_sessions( $workspace, (int) $owner['key'], $args );
}

function agents_conversation_sessions_principal( array $input ): ?WP_Agent_Execution_Principal {
if ( isset( $input['principal'] ) && $input['principal'] instanceof WP_Agent_Execution_Principal ) {
return $input['principal'];
Expand Down Expand Up @@ -335,14 +383,27 @@ function agents_conversation_sessions_owned_session( string $session_id, array $
return new \WP_Error( 'agents_conversation_session_not_found', 'Conversation session not found.' );
}

$principal = $context['principal'];
if ( (int) ( $session['user_id'] ?? 0 ) !== $principal->acting_user_id && ! agents_conversation_sessions_can_manage_any() ) {
if ( ! agents_conversation_sessions_session_matches_owner( $session, $context['owner'] ) && ! agents_conversation_sessions_can_manage_any() ) {
return new \WP_Error( 'agents_conversation_session_forbidden', 'The current principal cannot access this conversation session.' );
}

return $session;
}

/**
* @param array{type:string,key:string} $owner Canonical principal owner.
*/
function agents_conversation_sessions_session_matches_owner( array $session, array $owner ): bool {
$session_owner_type = $session['owner_type'] ?? $session['principal_owner_type'] ?? null;
$session_owner_key = $session['owner_key'] ?? $session['principal_owner_key'] ?? null;

if ( null !== $session_owner_type || null !== $session_owner_key ) {
return (string) $session_owner_type === $owner['type'] && (string) $session_owner_key === $owner['key'];
}

return WP_Agent_Execution_Principal::OWNER_TYPE_USER === $owner['type'] && (int) ( $session['user_id'] ?? 0 ) === (int) $owner['key'];
}

function agents_conversation_sessions_can_manage_any(): bool {
return function_exists( 'current_user_can' ) && current_user_can( 'manage_options' );
}
Expand Down
Loading
Loading