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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ wp_register_agent(
## Public Surface

- `wp_agents_api_init`
- `agents_api_loop_event`
- `wp_register_agent()` / `wp_get_agent()` / `wp_get_agents()` / `wp_has_agent()` / `wp_unregister_agent()`
- `WP_Agent`
- `WP_Agents_Registry`
Expand Down Expand Up @@ -159,6 +160,17 @@ wp_register_agent(
- `AgentsAPI\Core\Database\Chat\ConversationTranscriptStoreInterface`
- `AgentsAPI\Core\FilesRepository\AgentMemoryStoreInterface` and memory value objects, including provenance/trust metadata contracts

## Conversation Loop Events

`AgentConversationLoop` exposes lifecycle events through two observer surfaces:

- The caller-owned `on_event` option: `fn( string $event, array $payload ): void`.
- The WordPress `agents_api_loop_event` action: `do_action( 'agents_api_loop_event', $event, $payload )`.

The callable sink is for the component that directly invokes the loop. The WordPress action is for independent observers such as logging, tracing, metrics, or transcript diagnostics.

Event payloads are read-only snapshots. Observers must not rely on mutating payloads to affect loop behavior. Exceptions thrown by either observer surface are swallowed by the loop, so logging or tracing failures cannot break provider execution, tool mediation, budget enforcement, transcript persistence, or the returned result.

## Memory Provenance Metadata

Memory stores can carry first-class metadata alongside content so callers can distinguish direct user assertions from agent inferences, workspace extraction, curated facts, system-generated facts, or imports.
Expand Down
38 changes: 28 additions & 10 deletions src/Runtime/AgentConversationLoop.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class AgentConversationLoop {
* - `tool_declarations` (array|null): Tool declarations keyed by name.
* - `completion_policy` (AgentConversationCompletionPolicyInterface|null): Typed completion policy.
* - `transcript_persister` (AgentConversationTranscriptPersisterInterface|null): Transcript persister.
* - `on_event` (callable|null): Lifecycle event sink `fn(string $event, array $payload)`.
* - `on_event` (callable|null): Caller-owned lifecycle event sink `fn(string $event, array $payload)`.
*
* @param array $messages Initial transcript messages.
* @param callable $turn_runner Caller-owned turn adapter.
Expand Down Expand Up @@ -410,24 +410,42 @@ private static function persist_transcript(
}

/**
* Emit a lifecycle event through the event sink.
* Emit a lifecycle event through the caller sink and WordPress observers.
*
* Observer failures are swallowed to prevent changing loop results.
* The caller-owned `on_event` sink and the `agents_api_loop_event` action are
* observational surfaces. Event payloads are read-only snapshots for observers;
* observer failures are swallowed to prevent changing loop results.
*
* @param callable|null $on_event Event sink.
* @param string $event Event name.
* @param array $payload Event payload.
*/
private static function emit_event( ?callable $on_event, string $event, array $payload = array() ): void {
if ( null === $on_event ) {
return;
if ( null !== $on_event ) {
try {
call_user_func( $on_event, $event, $payload );
} catch ( \Throwable $error ) {
// Observer failures must not change loop results.
unset( $error );
}
}

try {
call_user_func( $on_event, $event, $payload );
} catch ( \Throwable $error ) {
// Observer failures must not change loop results.
unset( $error );
if ( function_exists( 'do_action' ) ) {
try {
/**
* Fires when AgentConversationLoop emits a lifecycle event.
*
* Observers receive read-only event snapshots. Exceptions thrown by
* observers are swallowed and cannot change loop results.
*
* @param string $event Event name.
* @param array $payload Event payload snapshot.
*/
do_action( 'agents_api_loop_event', $event, $payload );
} catch ( \Throwable $error ) {
// Observer failures must not change loop results.
unset( $error );
}
}
}

Expand Down
59 changes: 58 additions & 1 deletion tests/conversation-loop-events-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,64 @@ static function ( array $messages ): array {

agents_api_smoke_assert_equals( 2, count( $crashing_sink_result['messages'] ), 'loop result is unaffected by event sink crash', $failures, $passes );

echo "\n[4] No event sink = no events emitted (backwards compatible):\n";
echo "\n[4] WordPress action observers receive events without caller sink:\n";
$action_event_log = array();

add_action(
'agents_api_loop_event',
static function ( string $event, array $payload ) use ( &$action_event_log ): void {
$action_event_log[] = array( 'event' => $event, 'payload' => $payload );
},
10,
2
);

$action_observed_result = AgentsAPI\AI\AgentConversationLoop::run(
array( array( 'role' => 'user', 'content' => 'observe' ) ),
static function ( array $messages ): array {
$messages[] = AgentsAPI\AI\AgentMessageEnvelope::text( 'assistant', 'observed' );

return array(
'messages' => $messages,
'tool_execution_results' => array(),
'events' => array(),
);
},
array( 'max_turns' => 1 )
);

$action_event_names = array_column( $action_event_log, 'event' );
agents_api_smoke_assert_equals( 2, count( $action_observed_result['messages'] ), 'loop works with action observer and no caller sink', $failures, $passes );
agents_api_smoke_assert_equals( true, in_array( 'turn_started', $action_event_names, true ), 'action observer receives turn_started event', $failures, $passes );
agents_api_smoke_assert_equals( true, in_array( 'completed', $action_event_names, true ), 'action observer receives completed event', $failures, $passes );

echo "\n[5] WordPress action observer failure does not affect loop result:\n";
add_action(
'agents_api_loop_event',
static function (): void {
throw new \RuntimeException( 'action observer crash' );
},
20,
2
);

$crashing_action_result = AgentsAPI\AI\AgentConversationLoop::run(
array( array( 'role' => 'user', 'content' => 'hello' ) ),
static function ( array $messages ): array {
$messages[] = AgentsAPI\AI\AgentMessageEnvelope::text( 'assistant', 'hi' );

return array(
'messages' => $messages,
'tool_execution_results' => array(),
'events' => array(),
);
},
array( 'max_turns' => 1 )
);

agents_api_smoke_assert_equals( 2, count( $crashing_action_result['messages'] ), 'loop result is unaffected by action observer crash', $failures, $passes );

echo "\n[6] No caller event sink remains optional:\n";
$no_event_result = AgentsAPI\AI\AgentConversationLoop::run(
array( array( 'role' => 'user', 'content' => 'hello' ) ),
static function ( array $messages ): array {
Expand Down
Loading