Skip to content

BroadcastAgent broadcasts ToolResult/ToolCall events that exceed WebSocket message limits #174

@timkley

Description

@timkley

BroadcastAgent broadcasts every StreamEvent via the configured broadcast driver (Reverb/Pusher), including ToolResult and ToolCall events. Tool results from agents with data-returning tools (listing records, searching, etc.) regularly exceed the 10 KB WebSocket message limit, causing a BroadcastException that fails the entire queued job — killing the stream mid-conversation.

Steps to reproduce

// 1. Create an agent with a tool that returns a large result
#[MaxSteps(5)]
class MyAgent implements Agent, HasTools
{
    use Promptable;

    public function instructions(): string
    {
        return 'You help search for records.';
    }

    public function tools(): iterable
    {
        return [new SearchTool];
    }
}

// 2. The tool returns >10KB of data
class SearchTool implements Tool
{
    public function description(): string
    {
        return 'Search records';
    }

    public function schema(): array
    {
        return ['type' => 'object', 'properties' => ['query' => ['type' => 'string']]];
    }

    public function handle(Request $request): string
    {
        // Returns ~15KB of JSON — normal for listing 50+ records
        return json_encode(Record::query()->limit(50)->get()->toArray());
    }
}

// 3. Broadcast the agent on a queue
$agent = new MyAgent;
$agent->broadcastOnQueue('Search for all active records', $channel);

Expected: The stream completes successfully and the frontend receives text deltas.

Actual: The job fails with:

Illuminate\Broadcasting\BroadcastException: Pusher error: Payload too large.

The exception originates in PusherBroadcaster::broadcast() when Pusher::trigger() rejects the oversized payload. The entire queued job fails, so the user sees no response at all — not even the text deltas that were broadcast before the tool result.

Root cause

In BroadcastAgent::handle(), every StreamEvent is broadcast without any size check or filtering:

public function handle(): void
{
    $this->withCallbacks(fn () => $this->agent->stream(...)
        ->each(function (StreamEvent $event) {
            $event->broadcastNow($this->channels);  // <-- broadcasts everything
        })
    );
}

The ToolResult event includes the full tool result string in its toArray() payload:

// ToolResult::toArray()
return [
    'result' => $this->toolResult->result,  // <-- can be 10KB+
    // ...
];

Reverb and Pusher both enforce a 10 KB per-message limit on WebSocket frames. Tool results from real-world agents (listing records, search results, email bodies) easily exceed this.

Impact

  • Job failure kills the entire stream. The BroadcastException is unhandled, so the queued job fails. All previously broadcast text deltas are lost because the frontend relies on stream_completed/stream_end to finalize the conversation — which never arrives.
  • No partial recovery. Even though the AI response and tool results are persisted in the agent_conversation_messages table, the user sees a broken/hanging chat because is_processing is never reset (the .then() callback never fires).

Possible solutions

Option A: Allow opting out of broadcasting specific event types

Allow agents to opt out of broadcasting specific event types. Many frontends display tool calls in real-time (e.g. showing a spinner with the tool name, or streaming partial results), so tool events shouldn't be dropped silently by default. But the current behavior — broadcasting the full, untruncated tool result — makes it impossible to use tools that return more than 10 KB.

// Promptable::broadcastOnQueue()
public function broadcastOnQueue(
    string $prompt,
    Channel|array $channels,
    array $attachments = [],
    array $except = [],  // e.g. [ToolResult::class] to skip large payloads
): QueuedAgentResponse

Option B: Truncate large payloads before broadcasting

Add a size check in StreamEvent::broadcast() and truncate the payload if it exceeds a threshold:

public function broadcast(Channel|array $channels, bool $now = false): void
{
    $payload = $this->toArray();

    if (strlen(json_encode($payload)) > 9_000) {
        $payload['result'] = mb_substr($payload['result'] ?? '', 0, 5_000) . '... (truncated)';
    }

    // ... broadcast $payload
}

This would allow frontends to display a summary (e.g. tool name + "loading...") while the full result is available from the conversation history after the stream completes.

Downside: Lossy — consumers that display the real-time tool result would see truncated data. Needs a clear indicator (e.g. a truncated: true flag) so the frontend knows to fetch the full result from the database.

Option C: Catch BroadcastException and continue streaming

Wrap the broadcast call in a try/catch so a single oversized event doesn't kill the entire stream:

->each(function (StreamEvent $event) {
    try {
        $event->broadcastNow($this->channels);
    } catch (BroadcastException $e) {
        report($e);  // Log but don't fail the job
    }
})

Downside: Masks the real problem. The oversized events are still being serialized and sent to the broadcast driver, wasting resources. The client silently misses the tool result with no indication that it was dropped.

Option D: Allow agents to configure which events to broadcast

Add a broadcastableEvents() method to the Agent contract:

interface Agent
{
    // ...
    public function broadcastableEvents(): array;  // default: all StreamEvent subclasses
}
// In BroadcastAgent::handle()
$allowed = $this->agent->broadcastableEvents();

->each(function (StreamEvent $event) use ($allowed) {
    if (! in_array(get_class($event), $allowed)) {
        return;
    }
    $event->broadcastNow($this->channels);
})

Upside: Maximum flexibility. Agents that want real-time tool results can opt in.
Downside: More API surface. Most agents would use the same default.

Recommendation

A combination of Option B + Option D would cover the most use cases:

  • Truncate by default (Option B) so tool events are still broadcast for frontends that show real-time tool activity (tool name, partial result), but without exceeding the WebSocket limit. Include a truncated: true flag so the frontend knows to fetch the full result from conversation history.
  • Allow opting out (Option D) for agents that don't need tool events broadcast at all, avoiding unnecessary serialization overhead.

Happy to provide PR.

Environment

  • laravel/ai: v0.1.5
  • Broadcasting driver: Reverb (also affects Pusher)
  • PHP: 8.4
  • Laravel: 12.x

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions