Skip to content

Clarify event sink contract: on_event callable vs documented filter #75

@lezama

Description

@lezama

Question

AgentConversationLoop::run() already accepts an on_event callable that receives lifecycle events (turn_started, tool_call, tool_result, budget_exceeded, completed, failed). For a single consumer this is enough.

The unanswered question: when multiple independent observers want to attach to the same loop — telemetry, audit log, async replay queue, dev-mode console logger — does the substrate ship its own multiplexer, or is the consumer expected to compose one above on_event?

This isn't urgent, but it's the kind of decision that's painful to revisit later because every adopter writes their own multiplexer with subtly different ordering and error semantics.

Two shapes worth deciding between

Option A — on_event is canonical, multiplexing is the consumer's problem

Document the existing callable as the only extension point. Consumers that need multiple sinks compose their own:

$on_event = function ( string $event, array $payload ) use ( $sinks ): void {
    foreach ( $sinks as $sink ) {
        try { $sink( $event, $payload ); } catch ( \Throwable $e ) { /* log, continue */ }
    }
};

Pros: zero new substrate surface. Maximum flexibility — consumers control ordering, error handling, async dispatch.
Cons: every consumer reinvents the multiplexer with subtly different error semantics. Two plugins both wanting to observe the same loop have no documented way to compose without one wrapping the other.

Option B — Add documented agents_api_loop_event action

Substrate emits a WordPress action alongside the on_event callable:

do_action( 'agents_api_loop_event', $event, $payload, $loop_context );

Independent observers attach with add_action(). Standard WP composition rules apply.

Pros: idiomatic for WP host plugins. Composition is free. Independent observers can attach without coordinating.
Cons: two extension points (callable + action) to document and reason about. Action-based observers can't easily mutate the event payload — needs a clear stance on whether observers are read-only.

Option C — Replace the callable with the action

Cut the callable, route everything through the action. Cleanest surface but a public-API break for any caller already using on_event.

Resolution would inform

  • Whether a future PR introduces agents_api_loop_event (or similarly named) and what its lifecycle is relative to the on_event callable.
  • Whether observers are guaranteed read-only (action) or can transform the payload (filter).
  • Whether sink errors halt the loop (transparent throw) or are swallowed (per-sink try/catch in the multiplexer).
  • Whether async sinks are in scope (the substrate stays sync; consumers queue if they need async).

Recommendation (weak)

Option B with action semantics: keep the existing on_event callable for tightly-coupled callers (the request adapter wiring), and add agents_api_loop_event as the canonical extension point for cross-cutting observers. Sink errors are caught and logged but do not halt the loop — observability must never break the agent's actual job.

Open to the other shapes; filing this as a decision issue rather than a build issue.

AI assistance

  • AI assistance: Yes
  • Tool(s): Claude Code (Opus 4.7)
  • Used for: Auditing AgentConversationLoop for existing extension points and drafting the decision options.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions