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
11 changes: 9 additions & 2 deletions docs/auth-consent-context-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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:
Expand Down
14 changes: 10 additions & 4 deletions src/Channels/register-agents-chat-ability.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ static function ( array $claimed_input ) use ( $handler ) {
* claim token are injected before `$run` executes.
* @param callable(array<mixed>):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<string,mixed>|\WP_Error Canonical output, or WP_Error when the run cannot be claimed
* (e.g. a replayed run_id) or the runtime fails.
Expand All @@ -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() );
}
Expand All @@ -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;
Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -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' ) ),
),
);
Expand Down
10 changes: 6 additions & 4 deletions src/Runtime/class-wp-agent-chat-run-control.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public static function statuses(): array {
* Resolve canonical workspace, principal, and conversation store context.
*
* @param array<mixed> $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;
Expand Down Expand Up @@ -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 ) ),
);
}
Expand Down Expand Up @@ -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<string,mixed>|null $owner Canonical conversation owner.
* @param array<string,mixed> $metadata Safe run audit metadata.
* @return array<string,mixed>|\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.' ) );
}
Expand All @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion src/Runtime/class-wp-agent-conversation-loop.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down
58 changes: 56 additions & 2 deletions src/Runtime/class-wp-agent-execution-principal.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,mixed>|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,
Expand All @@ -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' );
Expand Down Expand Up @@ -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' );
}
}

/**
Expand Down Expand Up @@ -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' )
);
}

Expand All @@ -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,
);
}

Expand All @@ -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,
Expand All @@ -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,
);
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
);
}

Expand Down
Loading