Skip to content

Observing Agent Execution

Introduction

For responsive chat interfaces, it is usually not enough to wait for execute() to finish and then render the final answer. The Agents package exposes the execution lifecycle through events, and the AgentEventBroadcaster converts selected events into UI-friendly envelopes that can be forwarded to SSE, WebSocket, or any custom transport.

This gives your application a simple observation layer:

  • live text chunks while the LLM is producing output
  • step and tool status updates while the agent is working
  • execution status transitions such as processing, completed, and failed

The broadcaster is observation-only. It does not change agent behavior or persist any state. It listens to events already emitted by the agent loop and forwards them in a consistent format for your UI.

When to Use It

Use AgentEventBroadcaster when your app needs to reflect agent progress while execution is still in flight:

  • chat UIs that should show text as it arrives
  • TUIs that need progress indicators and tool activity
  • web apps that stream agent status over SSE or WebSockets
  • observability dashboards that track step, tool, and continuation events

If you only need the final answer, execute() is enough. If you need step-by-step inspection, use iterate(). If you need UI updates during execution, attach a broadcaster.

How It Works

AgentLoop, the active driver, and the underlying inference runtime share the same event dispatcher. AgentEventBroadcaster listens to that dispatcher and translates selected events into envelopes such as:

  • agent.status
  • agent.step.started
  • agent.step.completed
  • agent.tool.started
  • agent.tool.completed
  • agent.stream.chunk

Your application provides the final transport by implementing CanBroadcastAgentEvents.

Required Steps

To make agent events available to your application, set up four pieces:

  1. Create the agent with a shared event bus. The default builder and AgentLoop::default() already do this.
  2. Implement CanBroadcastAgentEvents to forward envelopes to your transport.
  3. Add UseAgentBroadcasting to the builder so the relevant listeners are registered for you.
  4. If you want streamed text chunks, make sure the LLM request is created with stream: true.

Minimal example:

use Cognesy\Agents\Builder\AgentBuilder;
use Cognesy\Agents\Broadcasting\CanBroadcastAgentEvents;
use Cognesy\Agents\Capability\Broadcasting\UseAgentBroadcasting;
use Cognesy\Agents\Data\AgentState;

final class SseTransport implements CanBroadcastAgentEvents
{
    public function broadcast(string $channel, array $envelope): void {
        // Forward the envelope to SSE, WebSocket, Redis, etc.
    }
}

$agent = AgentBuilder::base()
    ->withCapability(new UseAgentBroadcasting(
        broadcaster: new SseTransport(),
        sessionId: 'chat-42',
    ))
    ->build();

$result = $agent->execute(
    AgentState::empty()->withUserMessage('Explain closures in PHP.')
);
// @doctest id="19d0"

Once installed, the capability listens to the relevant execution, step, tool, continuation, and streaming events and emits normalized envelopes through your transport. Your app consumes those envelopes and updates the UI.

Enabling Live Text Chunks

agent.stream.chunk is emitted only when the underlying inference request is streamed. Attaching a broadcaster alone is not enough: the LLM request must be created with streaming enabled.

In practice, that means configuring the driver so its inference request includes stream: true in the request options.

Minimal example with an explicit driver:

use Cognesy\Agents\AgentLoop;
use Cognesy\Agents\Drivers\ToolCalling\ToolCallingDriver;
use Cognesy\Events\Dispatchers\EventDispatcher;
use Cognesy\Polyglot\Inference\InferenceRuntime;
use Cognesy\Polyglot\Inference\LLMProvider;

$events = new EventDispatcher('agent');
$llm = LLMProvider::new();

$agent = AgentLoop::default()->withDriver(
    new ToolCallingDriver(
        llm: $llm,
        inference: InferenceRuntime::fromProvider($llm, events: $events),
        options: ['stream' => true],
        events: $events,
    )
);
// @doctest id="321b"

Without streamed inference, you still receive step, tool, and status envelopes, but not incremental text chunks.

Transport Integration

AgentEventBroadcaster emits envelopes through a simple contract:

interface CanBroadcastAgentEvents
{
    public function broadcast(string $channel, array $envelope): void;
}
// @doctest id="8eb1"

This keeps the integration boundary small. The broadcaster does not require a framework or network stack. Your implementation decides how envelopes leave the process.

Typical patterns:

  • SSE endpoint writes each envelope as a server-sent event
  • WebSocket handler publishes envelopes to a client-specific channel
  • queue worker forwards envelopes to Redis or another pub/sub layer
  • CLI/TUI adapter renders envelopes directly to the terminal

Advanced Option

If you need lower-level control, you can create AgentEventBroadcaster yourself and attach it via $agent->wiretap($broadcaster->wiretap()). UseAgentBroadcasting is just the prewired integration path for the event set that is usually useful in interactive applications.

Choosing a Broadcast Configuration

BroadcastConfig provides three presets:

  • minimal() for status-only tracking
  • standard() for status plus streamed text chunks
  • debug() for status, stream chunks, continuation trace, and tool arguments

Minimal example:

use Cognesy\Agents\Broadcasting\BroadcastConfig;

$broadcaster = new AgentEventBroadcaster(
    broadcaster: new SseTransport(),
    sessionId: 'chat-42',
    executionId: 'exec-1',
    config: BroadcastConfig::standard(),
);
// @doctest id="7925"

For most user-facing apps, standard() is the right default.

What the UI Can Rely On

The broadcaster emits stable, app-facing event types. A typical UI flow looks like this:

  1. agent.status changes to processing
  2. agent.step.started appears
  3. zero or more agent.stream.chunk envelopes arrive
  4. zero or more agent.tool.started / agent.tool.completed envelopes arrive
  5. agent.step.completed appears
  6. agent.status changes to completed, failed, cancelled, or stopped

This is usually enough to drive:

  • typing indicators
  • streaming assistant text
  • tool activity rows
  • execution status badges

Notes

  • AgentEventBroadcaster is transport-agnostic. It formats envelopes but does not send HTTP responses or manage sockets.
  • The broadcaster does not replace iterate(). Use iterate() for application-side control flow, and use broadcasting for UI observation.
  • If you need additional behavior, you can attach your own listeners alongside the broadcaster with onEvent().

Describing the Resolved Agent

Use describe() when a host application, CLI, test, or operator needs a deterministic description of the agent that was actually built:

$description = $agent->describe();

$data = $description->toArray();
$text = $description->toText();
$markdown = $description->toMarkdown();
// @doctest id="39d1"

The description includes the resolved identity, driver, safe LLM configuration, installed capabilities, resolved tools, registered hooks, self-knowledge summary, and public metadata. Tool entries use the same resolved names and prompt-visibility rule as prompt composition. Hook entries are ordered by lifecycle trigger, then priority, then registration order.

toArray() is the machine-readable contract. Its field order and collection ordering are deterministic within a major release; minor releases may add fields. Consumers should read fields by name and tolerate additions.

The LLM section is intentionally a safe projection. It may include the driver, model, and token limits, but never exposes API keys, authorization headers, credential-bearing URLs, provider options, or arbitrary connection metadata.

The tool list describes tools installed on the resolved loop. It is not a promise that every tool call will be authorized in every state: guards and policy hooks can still block a tool at execution time.

Letting the Agent Describe Itself

Self-description is opt-in. Add UseSelfDescription to install the describe_self tool:

use Cognesy\Agents\Builder\AgentBuilder;
use Cognesy\Agents\Capability\Describe\UseSelfDescription;

$agent = AgentBuilder::base()
    ->withCapability(new UseSelfDescription())
    ->build();
// @doctest id="8201"

The tool receives the final resolved profile after all capabilities, tools, hooks, and driver configuration have been applied. Its full response is therefore the same safe array returned by $agent->describe()->toArray(), including the describe_self tool itself.

An agent can request the full description or narrow the result to tools, capabilities, or hooks. Narrowing returns one top-level field with the same ordering and values as the full description; it does not create a second discovery path.