-
Notifications
You must be signed in to change notification settings - Fork 111
Description
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
BroadcastExceptionis unhandled, so the queued job fails. All previously broadcast text deltas are lost because the frontend relies onstream_completed/stream_endto finalize the conversation — which never arrives. - No partial recovery. Even though the AI response and tool results are persisted in the
agent_conversation_messagestable, the user sees a broken/hanging chat becauseis_processingis 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
): QueuedAgentResponseOption 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: trueflag 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