diff --git a/docs/auth-consent-context-memory.md b/docs/auth-consent-context-memory.md index 29380bf..652e045 100644 --- a/docs/auth-consent-context-memory.md +++ b/docs/auth-consent-context-memory.md @@ -60,7 +60,7 @@ Missing headers produce a top-of-chain context. Malformed headers throw `Invalid ## Execution principals and workspace scope -`AgentsAPI\AI\WP_Agent_Execution_Principal` represents one runtime actor: acting user id, effective agent id/slug, auth source, request context, optional token id, workspace id, client id, capability ceiling, caller context, non-user audience id/claims, optional transcript owner, and JSON-friendly metadata. +`AgentsAPI\AI\WP_Agent_Execution_Principal` represents one runtime execution: acting user id, effective agent id/slug, auth source, request context, optional token id, workspace id, client id, capability ceiling, caller context, non-user audience id/claims, optional transcript owner, optional current-turn actor, and JSON-friendly metadata. Generic principal fields: @@ -74,14 +74,21 @@ Generic principal fields: | `client_id` | Host/client surface identifier, such as a frontend, bridge, runtime, or API client. | | `audience_id` / `audience_claims` | Host-resolved non-user audience context. Claims are private runtime/audit data, not a display contract. | | `owner_type` / `owner_key` | Optional transcript/session owner. Non-user principals must use opaque host-owned owner keys; audience access alone is not a transcript owner. | +| `actor_type` / `actor_key` | Optional opaque current-turn actor supplied explicitly by a trusted channel integration. Both fields are required together. This identifies the authenticated speaker responsible for this turn and is distinct from the conversation owner, task owner, approver, and audience. | | `capability_ceiling` | Optional ceiling intersected by authorization policy with WordPress capabilities. | | `caller_context` | Cross-agent/cross-host chain context for delegation and loop prevention. | | `request_metadata` / `binding` | Private host audit/runtime data. These fields are not safe citation or frontend metadata. | -Permission-aware tools and retrieval surfaces should attach only safe principal metadata to user-visible citations, diagnostics, or frontend result objects. `to_safe_metadata()` returns the generic safe shape: schema version, effective agent, auth source, request context, acting user id, workspace id, client id, audience id, owner type, and boolean flags for conversation-owner/capability/caller-context presence. It intentionally omits token ids, owner keys, request metadata, audience claims, capability details, and cryptographic binding claims. +Permission-aware tools and retrieval surfaces should attach only safe principal metadata to user-visible citations, diagnostics, or frontend result objects. `to_safe_metadata()` returns the generic safe shape: schema version, effective agent, auth source, request context, acting user id, workspace id, client id, audience id, owner type, actor presence/type, and boolean flags for conversation-owner/capability/caller-context presence. It intentionally omits token ids, owner keys, actor keys, request metadata, audience claims, capability details, and cryptographic binding claims. Hosts that need richer audit trails should persist the full `to_array()` shape in private storage they control. Frontend clients, citations, and source diagnostics should use the safe metadata shape plus result-level status fields instead of receiving raw credentials, tokens, opaque session ids, or authorization internals. +## External turn actors + +Trusted channel integrations may provide `actor_type` and `actor_key` in the canonical `principal` object passed to `agents/chat`. The pair is immutable for that execution and is preserved through the canonical chat principal, conversation request/context, and safe run-control audit metadata. For a WordPress-user principal without an explicit pair, `turn_actor()` derives `array( 'type' => 'user', 'key' => (string) $acting_user_id )` for compatibility. + +The integration must authenticate the external speaker before constructing the principal and use an opaque host-owned key. Display names, message text, model-supplied identity, ambient request keys, and audience membership are not actor proof and Agents API does not infer an actor from them. The substrate does not resolve account links, credentials, private memory, consent, or approvals from this pair. An actor neither owns the conversation nor grants WordPress capabilities, audience access, task/run control, or any other authority; hosts that need person-scoped authorization or credentials must implement that policy explicitly outside this value object. + Client context is caller-owned runtime context carried on conversation requests and frontend channel payloads. It may describe selected UI state, host context, explicit routing hints, or opaque client metadata, but Agents API does not infer tool arguments from it. Tool declarations must opt in with `client_context_bindings`, either as `array( 'parameter_name' )` or `array( 'parameter_name' => 'context_key' )`. Sensitive ambient keys such as `api_key`, `token`, `authorization`, `cookie`, `nonce`, or `password` must be passed only through explicit bindings/defaults and host-owned authorization policy; matching key names alone never satisfies required parameters. Permission-aware retrieval results should use product-neutral status vocabulary so frontend clients can explain restricted output without learning product-specific policy: diff --git a/src/Channels/register-agents-chat-ability.php b/src/Channels/register-agents-chat-ability.php index 4a39268..eeb8048 100644 --- a/src/Channels/register-agents-chat-ability.php +++ b/src/Channels/register-agents-chat-ability.php @@ -206,7 +206,7 @@ static function ( array $claimed_input ) use ( $handler ) { * claim token are injected before `$run` executes. * @param callable(array):mixed $run Runtime callable. Receives the claimed input and * returns canonical output array or WP_Error. - * @param array{workspace:?\AgentsAPI\Core\Workspace\WP_Agent_Workspace_Scope,owner:?array{type:string,key:string},conversation_store:?\AgentsAPI\Core\Database\Chat\WP_Agent_Conversation_Store}|null $run_context + * @param array{workspace:?\AgentsAPI\Core\Workspace\WP_Agent_Workspace_Scope,owner:?array{type:string,key:string},principal:?WP_Agent_Execution_Principal,conversation_store:?\AgentsAPI\Core\Database\Chat\WP_Agent_Conversation_Store}|null $run_context * Pre-resolved context when the caller must validate before handler selection. * @return array|\WP_Error Canonical output, or WP_Error when the run cannot be claimed * (e.g. a replayed run_id) or the runtime fails. @@ -228,9 +228,13 @@ function agents_chat_run_claimed( array $input, callable $run, ?array $run_conte $session_id = agents_chat_optional_string( $input['session_id'] ?? null ); $agent = agents_chat_optional_string( $input['agent'] ?? null ) ?? ''; + $metadata = array( 'agent' => $agent ); + if ( $run_context['principal'] instanceof WP_Agent_Execution_Principal ) { + $metadata['principal'] = $run_context['principal']->to_safe_metadata(); + } if ( null !== $session_id ) { try { - $started = WP_Agent_Chat_Run_Control::start_run( $run_id, $session_id, array( 'agent' => $agent, '_claim_token' => $run_claim_token ), $run_context['workspace'], $run_context['owner'], $run_context['conversation_store'] ); + $started = WP_Agent_Chat_Run_Control::start_run( $run_id, $session_id, $metadata + array( '_claim_token' => $run_claim_token ), $run_context['workspace'], $run_context['owner'], $run_context['conversation_store'] ); } catch ( \RuntimeException $error ) { return new \WP_Error( 'agents_chat_run_workspace_unsupported', $error->getMessage() ); } @@ -239,7 +243,7 @@ function agents_chat_run_claimed( array $input, callable $run, ?array $run_conte return $started; } } else { - $pending = WP_Agent_Chat_Run_Control::claim_pending_run( $run_id, $run_claim_token, $run_context['workspace'], $run_context['owner'] ); + $pending = WP_Agent_Chat_Run_Control::claim_pending_run( $run_id, $run_claim_token, $run_context['workspace'], $run_context['owner'], $metadata ); if ( is_wp_error( $pending ) ) { do_action( 'agents_chat_dispatch_failed', $pending->get_error_code(), $input ); return $pending; @@ -283,7 +287,7 @@ function agents_chat_run_claimed( array $input, callable $run, ?array $run_conte $resolved_session_id = agents_chat_optional_string( $result['session_id'] ?? null ) ?? $session_id; if ( null !== $resolved_session_id ) { if ( null === $session_id ) { - $started = WP_Agent_Chat_Run_Control::start_run( $run_id, $resolved_session_id, array( 'agent' => $agent, '_claim_token' => $run_claim_token ), $run_context['workspace'], $run_context['owner'], $run_context['conversation_store'] ); + $started = WP_Agent_Chat_Run_Control::start_run( $run_id, $resolved_session_id, $metadata + array( '_claim_token' => $run_claim_token ), $run_context['workspace'], $run_context['owner'], $run_context['conversation_store'] ); if ( is_wp_error( $started ) ) { WP_Agent_Chat_Run_Control::finish_run( $run_id, WP_Agent_Chat_Run_Control::STATUS_FAILED, $run_context['workspace'] ); do_action( 'agents_chat_dispatch_failed', $started->get_error_code(), $input ); @@ -650,6 +654,8 @@ function agents_chat_principal_schema(): array { 'audience_claims' => array( 'type' => 'object' ), 'owner_type' => array( 'type' => array( 'string', 'null' ) ), 'owner_key' => array( 'type' => array( 'string', 'null' ) ), + 'actor_type' => array( 'type' => array( 'string', 'null' ), 'description' => 'Optional opaque current-turn actor type supplied by a trusted channel integration. It is distinct from conversation ownership and audience.' ), + 'actor_key' => array( 'type' => array( 'string', 'null' ), 'description' => 'Optional opaque current-turn actor key. Must be supplied with actor_type; Agents API does not expose it in safe metadata.' ), 'binding' => array( 'type' => array( 'object', 'null' ) ), ), ); diff --git a/src/Runtime/class-wp-agent-chat-run-control.php b/src/Runtime/class-wp-agent-chat-run-control.php index 347080c..771141c 100644 --- a/src/Runtime/class-wp-agent-chat-run-control.php +++ b/src/Runtime/class-wp-agent-chat-run-control.php @@ -54,7 +54,7 @@ public static function statuses(): array { * Resolve canonical workspace, principal, and conversation store context. * * @param array $input Ability input. - * @return array{workspace:?WP_Agent_Workspace_Scope,owner:?array{type:string,key:string},conversation_store:?WP_Agent_Conversation_Store}|\WP_Error + * @return array{workspace:?WP_Agent_Workspace_Scope,owner:?array{type:string,key:string},principal:?WP_Agent_Execution_Principal,conversation_store:?WP_Agent_Conversation_Store}|\WP_Error */ public static function context_from_input( array $input ) { $workspace = null; @@ -118,6 +118,7 @@ public static function context_from_input( array $input ) { return array( 'workspace' => $workspace, 'owner' => $owner, + 'principal' => $principal, 'conversation_store' => WP_Agent_Conversation_Sessions::get_store( WP_Agent_Run_Control::string_keyed_array( $input ) ), ); } @@ -288,16 +289,17 @@ static function ( array $state ) use ( $run_id, $session_id, $metadata, $canonic * Reserve a run before a handler creates and returns its canonical session. * * @param array|null $owner Canonical conversation owner. + * @param array $metadata Safe run audit metadata. * @return array|\WP_Error */ - public static function claim_pending_run( string $run_id, string $claim_token, ?WP_Agent_Workspace_Scope $workspace = null, ?array $owner = null ) { + public static function claim_pending_run( string $run_id, string $claim_token, ?WP_Agent_Workspace_Scope $workspace = null, ?array $owner = null, array $metadata = array() ) { $fingerprint = self::owner_fingerprint( $owner ); if ( $workspace instanceof WP_Agent_Workspace_Scope && '' === $fingerprint ) { return new \WP_Error( 'agents_chat_run_owner_required', 'Explicit workspace run control requires an authenticated conversation owner.' ); } try { $result = self::mutate_state( - static function ( array $state ) use ( $run_id, $claim_token, $fingerprint ): array { + static function ( array $state ) use ( $run_id, $claim_token, $fingerprint, $metadata ): array { if ( isset( $state['runs'][ $run_id ] ) ) { return array( 'state' => $state, 'result' => new \WP_Error( 'agents_chat_run_already_started', 'The run_id has already been claimed for execution.' ) ); } @@ -308,7 +310,7 @@ static function ( array $state ) use ( $run_id, $claim_token, $fingerprint ): ar 'status' => self::STATUS_RUNNING, 'started_at' => $now, 'updated_at' => $now, - 'metadata' => array(), + 'metadata' => $metadata, '_owner' => $fingerprint, '_claim_token' => $claim_token, '_session_pending' => true, diff --git a/src/Runtime/class-wp-agent-conversation-loop.php b/src/Runtime/class-wp-agent-conversation-loop.php index 788bc04..79967b2 100644 --- a/src/Runtime/class-wp-agent-conversation-loop.php +++ b/src/Runtime/class-wp-agent-conversation-loop.php @@ -145,7 +145,11 @@ public static function run( array $messages, ?callable $turn_runner = null, arra $messages = self::normalize_messages( $messages ); if ( '' !== $run_id && '' !== $lock_session_id ) { $conversation_store = ( $context['conversation_store'] ?? null ) instanceof \AgentsAPI\Core\Database\Chat\WP_Agent_Conversation_Store ? $context['conversation_store'] : null; - $started = WP_Agent_Chat_Run_Control::start_run( $run_id, $lock_session_id, array( 'source' => 'conversation_loop', '_claim_token' => WP_Agent_Run_Control::string_value( $context['_agents_run_claim_token'] ?? '' ) ), $run_workspace, $run_owner, $conversation_store ); + $run_metadata = array( 'source' => 'conversation_loop', '_claim_token' => WP_Agent_Run_Control::string_value( $context['_agents_run_claim_token'] ?? '' ) ); + if ( $principal instanceof WP_Agent_Execution_Principal ) { + $run_metadata['principal'] = $principal->to_safe_metadata(); + } + $started = WP_Agent_Chat_Run_Control::start_run( $run_id, $lock_session_id, $run_metadata, $run_workspace, $run_owner, $conversation_store ); if ( is_wp_error( $started ) ) { self::emit_event( $on_event, 'failed', array( 'error' => $started->get_error_message() ) ); return self::run_control_failure_result( $messages, $started ); diff --git a/src/Runtime/class-wp-agent-execution-principal.php b/src/Runtime/class-wp-agent-execution-principal.php index d70ee97..b4d4c5a 100644 --- a/src/Runtime/class-wp-agent-execution-principal.php +++ b/src/Runtime/class-wp-agent-execution-principal.php @@ -65,6 +65,8 @@ final class WP_Agent_Execution_Principal { * @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. * @param array|null $binding Optional host-owned cryptographic binding claims. + * @param string|null $actor_type Optional opaque current-turn external actor type. + * @param string|null $actor_key Optional opaque current-turn external actor key scoped to the actor type. */ public function __construct( public readonly int $acting_user_id, @@ -82,6 +84,8 @@ public function __construct( public readonly ?string $owner_type = null, public readonly ?string $owner_key = null, private readonly ?array $binding = null, + public readonly ?string $actor_type = null, + public readonly ?string $actor_key = null, ) { if ( $this->acting_user_id < 0 ) { throw self::invalid( 'acting_user_id', 'must be zero or a positive integer' ); @@ -134,6 +138,18 @@ public function __construct( if ( null !== $this->owner_key && '' === trim( $this->owner_key ) ) { throw self::invalid( 'owner_key', 'must be null or a non-empty string' ); } + + if ( ( null === $this->actor_type ) !== ( null === $this->actor_key ) ) { + throw self::invalid( 'actor', 'type and key must both be present or both be null' ); + } + + if ( null !== $this->actor_type && '' === trim( $this->actor_type ) ) { + throw self::invalid( 'actor_type', 'must be null or a non-empty string' ); + } + + if ( null !== $this->actor_key && '' === trim( $this->actor_key ) ) { + throw self::invalid( 'actor_key', 'must be null or a non-empty string' ); + } } /** @@ -298,7 +314,9 @@ public static function from_array( array $principal ): self { self::assoc_array_field( $principal, 'audience_claims' ), self::nullable_string_field( $principal, 'owner_type' ), self::nullable_string_field( $principal, 'owner_key' ), - isset( $principal['binding'] ) && is_array( $principal['binding'] ) ? self::assoc_array( $principal['binding'] ) : null + isset( $principal['binding'] ) && is_array( $principal['binding'] ) ? self::assoc_array( $principal['binding'] ) : null, + self::nullable_string_field( $principal, 'actor_type' ), + self::nullable_string_field( $principal, 'actor_key' ) ); } @@ -324,6 +342,8 @@ public function to_array(): array { 'owner_type' => $this->owner_type, 'owner_key' => $this->owner_key, 'binding' => $this->binding, + 'actor_type' => $this->actor_type, + 'actor_key' => $this->actor_key, ); } @@ -340,6 +360,7 @@ public function to_array(): array { */ public function to_safe_metadata(): array { $owner = $this->conversation_owner(); + $actor = $this->turn_actor(); return array( 'schema_version' => 1, @@ -352,6 +373,8 @@ public function to_safe_metadata(): array { 'audience_id' => $this->audience_id, 'owner_type' => is_array( $owner ) ? $owner['type'] : null, 'has_conversation_owner' => is_array( $owner ), + 'actor_type' => is_array( $actor ) ? $actor['type'] : null, + 'has_turn_actor' => is_array( $actor ), 'has_capability_ceiling' => $this->capability_ceiling instanceof \WP_Agent_Capability_Ceiling, 'has_caller_context' => $this->caller_context instanceof \WP_Agent_Caller_Context, ); @@ -401,6 +424,35 @@ public function conversation_owner(): ?array { return null; } + /** + * Return the entity responsible for the current turn. + * + * Trusted channel integrations supply external actor references explicitly. + * The reference is audit/context data only: it does not grant WordPress + * capabilities, own a conversation, join an audience, or control a run. + * Existing WordPress-user principals derive a user actor when no explicit + * actor was supplied. + * + * @return array{type:string,key:string}|null Current-turn actor, or null when unknown. + */ + public function turn_actor(): ?array { + if ( null !== $this->actor_type && null !== $this->actor_key ) { + return array( + 'type' => $this->actor_type, + 'key' => $this->actor_key, + ); + } + + if ( $this->acting_user_id > 0 ) { + return array( + 'type' => self::OWNER_TYPE_USER, + 'key' => (string) $this->acting_user_id, + ); + } + + return null; + } + /** * Whether this principal represents an autonomous execution. * @@ -446,7 +498,9 @@ public function with_request_metadata( array $request_metadata ): self { $this->audience_claims, $this->owner_type, $this->owner_key, - $this->binding + $this->binding, + $this->actor_type, + $this->actor_key ); } diff --git a/tests/authorization-smoke.php b/tests/authorization-smoke.php index da4e246..2ea1c77 100644 --- a/tests/authorization-smoke.php +++ b/tests/authorization-smoke.php @@ -235,6 +235,19 @@ public function get_agent_ids_for_principal( AgentsAPI\AI\WP_Agent_Execution_Pri agents_api_smoke_assert_equals( true, $audience_policy->can_access_agent( $audience_principal, 'viewer-agent', WP_Agent_Access_Grant::ROLE_VIEWER ), 'policy accepts principal-aware audience grant', $failures, $passes ); agents_api_smoke_assert_equals( false, $audience_policy->can_access_agent( $audience_principal, 'viewer-agent', WP_Agent_Access_Grant::ROLE_OPERATOR ), 'policy rejects audience grant below operator level', $failures, $passes ); +$external_actor_only = AgentsAPI\AI\WP_Agent_Execution_Principal::from_array( + array( + 'acting_user_id' => 0, + 'effective_agent_id' => 'external-agent', + 'auth_source' => AgentsAPI\AI\WP_Agent_Execution_Principal::AUTH_SOURCE_AUDIENCE, + 'request_context' => AgentsAPI\AI\WP_Agent_Execution_Principal::REQUEST_CONTEXT_CHAT, + 'actor_type' => 'person', + 'actor_key' => 'channel-user:privileged-user', + ) +); +agents_api_smoke_assert_equals( false, $audience_policy->can( $external_actor_only, 'read' ), 'external actor does not become a WordPress capability identity', $failures, $passes ); +agents_api_smoke_assert_equals( false, $audience_policy->can_access_agent( $external_actor_only, 'viewer-agent', WP_Agent_Access_Grant::ROLE_VIEWER ), 'external actor does not become an audience access principal', $failures, $passes ); + echo "\n[autonomous deny-by-default] Agent-token principal without an allow-list cannot exercise content-mutating capabilities:\n"; // Owner 7 is a privileged WordPress user that CAN publish/edit/delete/manage. diff --git a/tests/chat-run-control-smoke.php b/tests/chat-run-control-smoke.php index dbfaa70..d7a8c3c 100644 --- a/tests/chat-run-control-smoke.php +++ b/tests/chat-run-control-smoke.php @@ -186,6 +186,27 @@ static function ( $handler, array $input ) use ( &$captured_chat_input ) { $stored = AgentsAPI\AI\Channels\agents_get_chat_run( array( 'session_id' => 'session-1', 'run_id' => $chat['run_id'] ) ); agents_api_smoke_assert_equals( 'completed', $stored['status'] ?? null, 'chat dispatch records completed run by default', $failures, $passes ); +$actor_chat = AgentsAPI\AI\Channels\agents_chat_dispatch( + array( + 'agent' => 'demo-agent', + 'message' => 'Hello from an external speaker', + 'session_id' => 'session-1', + 'principal' => array( + 'acting_user_id' => 123, + 'effective_agent_id' => 'demo-agent', + 'auth_source' => AgentsAPI\AI\WP_Agent_Execution_Principal::AUTH_SOURCE_USER, + 'request_context' => AgentsAPI\AI\WP_Agent_Execution_Principal::REQUEST_CONTEXT_CHAT, + 'actor_type' => 'person', + 'actor_key' => 'channel-user:alice', + ), + ) +); +$actor_run = AgentsAPI\AI\Channels\agents_get_chat_run( array( 'session_id' => 'session-1', 'run_id' => $actor_chat['run_id'] ) ); +agents_api_smoke_assert_equals( 'person', $captured_chat_input['principal']['actor_type'] ?? null, 'canonical chat forwards explicit turn actor', $failures, $passes ); +agents_api_smoke_assert_equals( 'channel-user:alice', $captured_chat_input['principal']['actor_key'] ?? null, 'canonical chat preserves opaque turn actor key for the runtime', $failures, $passes ); +agents_api_smoke_assert_equals( 'person', $actor_run['metadata']['principal']['actor_type'] ?? null, 'run envelope preserves actor type for audit', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 'actor_key', $actor_run['metadata']['principal'] ?? array() ), 'run envelope redacts opaque actor key', $failures, $passes ); + add_filter( 'wp_agent_chat_handler', static fn() => static fn( array $runtime_input ): array => array( diff --git a/tests/execution-principal-smoke.php b/tests/execution-principal-smoke.php index 63f5d3d..5b93a53 100644 --- a/tests/execution-principal-smoke.php +++ b/tests/execution-principal-smoke.php @@ -121,6 +121,7 @@ agents_api_smoke_assert_equals( null, $user_session->binding(), 'user_session binding defaults to null', $failures, $passes ); agents_api_smoke_assert_equals( null, $user_session->to_array()['binding'], 'principal exports null binding as no-op', $failures, $passes ); agents_api_smoke_assert_equals( array( 'type' => AgentsAPI\AI\WP_Agent_Execution_Principal::OWNER_TYPE_USER, 'key' => '99' ), $user_session->conversation_owner(), 'user_session derives user conversation owner', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'type' => AgentsAPI\AI\WP_Agent_Execution_Principal::OWNER_TYPE_USER, 'key' => '99' ), $user_session->turn_actor(), 'user_session derives user turn actor when omitted', $failures, $passes ); add_filter( 'agents_api_execution_principal', @@ -183,6 +184,33 @@ static function ( $principal, array $context ) { agents_api_smoke_assert_equals( array( 'type' => AgentsAPI\AI\WP_Agent_Execution_Principal::OWNER_TYPE_AUDIENCE, 'key' => 'browser-session:opaque-123' ), $audience_owner_principal->conversation_owner(), 'audience principal can carry opaque conversation owner key', $failures, $passes ); agents_api_smoke_assert_equals( 'browser-session:opaque-123', $audience_owner_principal->to_array()['owner_key'], 'principal exports owner key', $failures, $passes ); +$speaker_one = AgentsAPI\AI\WP_Agent_Execution_Principal::from_array( + array_merge( + $audience_owner_principal->to_array(), + array( + 'actor_type' => 'person', + 'actor_key' => 'channel-user:alice', + ) + ) +); +$speaker_two = AgentsAPI\AI\WP_Agent_Execution_Principal::from_array( + array_merge( + $audience_owner_principal->to_array(), + array( + 'actor_type' => 'person', + 'actor_key' => 'channel-user:bob', + ) + ) +); +agents_api_smoke_assert_equals( $speaker_one->conversation_owner(), $speaker_two->conversation_owner(), 'mixed speakers retain the shared conversation owner', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'type' => 'person', 'key' => 'channel-user:alice' ), $speaker_one->turn_actor(), 'first speaker keeps explicit external actor', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'type' => 'person', 'key' => 'channel-user:bob' ), $speaker_two->turn_actor(), 'sequential speaker keeps separate external actor', $failures, $passes ); +agents_api_smoke_assert_equals( 'person', $speaker_one->to_array()['actor_type'], 'principal round trip exports explicit actor type', $failures, $passes ); +agents_api_smoke_assert_equals( 'channel-user:alice', $speaker_one->to_array()['actor_key'], 'principal round trip exports explicit actor key', $failures, $passes ); +agents_api_smoke_assert_equals( 'person', $speaker_one->to_safe_metadata()['actor_type'], 'safe metadata includes actor type', $failures, $passes ); +agents_api_smoke_assert_equals( true, $speaker_one->to_safe_metadata()['has_turn_actor'], 'safe metadata records actor presence', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 'actor_key', $speaker_one->to_safe_metadata() ), 'safe metadata omits opaque actor key', $failures, $passes ); + $audience_from_array = AgentsAPI\AI\WP_Agent_Execution_Principal::from_array( array( 'acting_user_id' => 0, @@ -254,6 +282,20 @@ static function ( array $sources ): array { agents_api_smoke_assert_equals( true, str_contains( $e->getMessage(), 'token_id' ), 'zero token id is rejected', $failures, $passes ); } +try { + new AgentsAPI\AI\WP_Agent_Execution_Principal( 0, 'agent', AgentsAPI\AI\WP_Agent_Execution_Principal::AUTH_SOURCE_AUDIENCE, AgentsAPI\AI\WP_Agent_Execution_Principal::REQUEST_CONTEXT_CHAT, null, array(), null, null, null, null, 'audience:shared', array(), 'channel', 'shared-thread', null, 'person', null ); + agents_api_smoke_assert_equals( true, false, 'partial actor pair is rejected', $failures, $passes ); +} catch ( InvalidArgumentException $e ) { + agents_api_smoke_assert_equals( true, str_contains( $e->getMessage(), 'actor' ), 'partial actor pair is rejected', $failures, $passes ); +} + +try { + new AgentsAPI\AI\WP_Agent_Execution_Principal( 0, 'agent', AgentsAPI\AI\WP_Agent_Execution_Principal::AUTH_SOURCE_AUDIENCE, AgentsAPI\AI\WP_Agent_Execution_Principal::REQUEST_CONTEXT_CHAT, null, array(), null, null, null, null, 'audience:shared', array(), 'channel', 'shared-thread', null, '', 'channel-user:empty-type' ); + agents_api_smoke_assert_equals( true, false, 'empty actor type is rejected', $failures, $passes ); +} catch ( InvalidArgumentException $e ) { + agents_api_smoke_assert_equals( true, str_contains( $e->getMessage(), 'actor_type' ), 'empty actor type is rejected', $failures, $passes ); +} + $resource = fopen( 'php://memory', 'r' ); try { new AgentsAPI\AI\WP_Agent_Execution_Principal( 1, 'agent', AgentsAPI\AI\WP_Agent_Execution_Principal::AUTH_SOURCE_USER, AgentsAPI\AI\WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST, null, array( 'resource' => $resource ) );