Skip to content
Open
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
14 changes: 14 additions & 0 deletions resources/boost/skills/ai-sdk-development/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,20 @@ class MyAgent implements Agent

The `#[UseCheapestModel]` and `#[UseSmartestModel]` attributes are also available for automatic model selection.

The `#[WithoutBroadcasting]` attribute stops the given stream event types from broadcasting (e.g. data-heavy `ToolResult` payloads that exceed the WebSocket frame limit). The events are still streamed and persisted; they just never hit the channel:

```php
use Laravel\Ai\Attributes\WithoutBroadcasting;
use Laravel\Ai\Streaming\Events\{ToolCall, ToolResult};

#[WithoutBroadcasting(ToolResult::class, ToolCall::class)]
class SearchAgent implements Agent, HasTools
{
use Promptable;
// ...
}
```

### Tools

Implement the `HasTools` interface and scaffold tools with `php artisan make:tool`:
Expand Down
65 changes: 65 additions & 0 deletions src/Attributes/WithoutBroadcasting.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace Laravel\Ai\Attributes;

use Attribute;
use InvalidArgumentException;
use Laravel\Ai\Streaming\Events\StreamEvent;
use ReflectionClass;

#[Attribute(Attribute::TARGET_CLASS)]
final class WithoutBroadcasting
Comment thread
pushpak1300 marked this conversation as resolved.
{
/**
* The stream event classes that should not be broadcast.
*
* @var array<int, class-string<StreamEvent>>
*/
public array $events;

/**
* Create a new attribute instance.
*
* @param class-string<StreamEvent> ...$events
*/
public function __construct(string ...$events)
Comment thread
pushpak1300 marked this conversation as resolved.
{
foreach ($events as $event) {
if (! is_subclass_of($event, StreamEvent::class)) {
throw new InvalidArgumentException("[{$event}] is not a valid ".StreamEvent::class.' to exclude from broadcasting.');
}
}

$this->events = $events;
}

/**
* Determine if the given event is excluded by the resolved skip set.
*
* @param array<int, class-string<StreamEvent>> $events
*/
public static function excludes(array $events, StreamEvent $event): bool
{
return in_array($event::class, $events, true);
}

/**
* Get the stream event classes that should not be broadcast for the target agent.
*
* @return array<int, class-string<StreamEvent>>
*/
public static function eventsFor(?object $target): array
{
if ($target === null) {
return [];
}

$attributes = (new ReflectionClass($target))->getAttributes(self::class);

if ($attributes === []) {
return [];
}

return $attributes[0]->newInstance()->events;
}
}
9 changes: 8 additions & 1 deletion src/Jobs/BroadcastAgent.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Str;
use Laravel\Ai\Attributes\WithoutBroadcasting;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Streaming\Events\Error;
Expand Down Expand Up @@ -43,8 +44,14 @@ public function handle(): void
{
$streamedResponse = null;

$without = WithoutBroadcasting::eventsFor($this->agent);

$this->agent->stream($this->prompt, $this->attachments, $this->provider, $this->model)
->each(function (StreamEvent $event) {
->each(function (StreamEvent $event) use ($without) {
if (WithoutBroadcasting::excludes($without, $event)) {
return;
}

$event->withInvocationId($this->invocationId)->broadcastNow($this->channels);
})
->then(function ($response) use (&$streamedResponse) {
Expand Down
9 changes: 8 additions & 1 deletion src/Promptable.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Laravel\Ai\Attributes\Timeout as TimeoutAttribute;
use Laravel\Ai\Attributes\UseCheapestModel;
use Laravel\Ai\Attributes\UseSmartestModel;
use Laravel\Ai\Attributes\WithoutBroadcasting;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Events\AgentFailedOver;
use Laravel\Ai\Exceptions\FailoverableException;
Expand Down Expand Up @@ -153,8 +154,14 @@ public function queue(string $prompt, array $attachments = [], Lab|array|string|
*/
public function broadcast(string $prompt, Channel|array $channels, array $attachments = [], bool $now = false, Lab|array|string|null $provider = null, ?string $model = null): StreamableAgentResponse
{
$without = WithoutBroadcasting::eventsFor($this);

return $this->stream($prompt, $attachments, $provider, $model)
->each(function (StreamEvent $event) use ($channels, $now) {
->each(function (StreamEvent $event) use ($channels, $now, $without) {
if (WithoutBroadcasting::excludes($without, $event)) {
return;
}

$event->{$now ? 'broadcastNow' : 'broadcast'}($channels);
});
}
Expand Down
13 changes: 9 additions & 4 deletions src/Streaming/Events/StreamEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Laravel\Ai\Streaming\Events;

use Illuminate\Broadcasting\BroadcastException;
use Illuminate\Broadcasting\Channel;
use Illuminate\Support\Facades\Broadcast;

Expand All @@ -21,10 +22,14 @@ abstract public function toArray(): array;
*/
public function broadcast(Channel|array $channels, bool $now = false): void
{
Broadcast::on($channels)
->as($this->type())
->with($this->toArray())
->{$now ? 'sendNow' : 'send'}();
try {
Broadcast::on($channels)
->as($this->type())
->with($this->toArray())
->{$now ? 'sendNow' : 'send'}();
} catch (BroadcastException $e) {
report($e);
}
}

/**
Expand Down
31 changes: 31 additions & 0 deletions tests/Feature/BroadcastAgentTest.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
<?php

use Illuminate\Broadcasting\AnonymousEvent;
use Illuminate\Broadcasting\BroadcastException;
use Illuminate\Broadcasting\Channel;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\Facades\Event;
use Laravel\Ai\Jobs\BroadcastAgent;
use Laravel\Ai\Responses\StreamedAgentResponse;
Expand Down Expand Up @@ -128,6 +130,35 @@
expect(array_values(array_unique($broadcastIds)))->toBe([$invocationId]);
});

test('an oversized broadcast frame does not abort the stream and then still resolves', function () {
AssistantAgent::fake(['Hello world']);

$pending = Mockery::mock(AnonymousEvent::class);
$pending->shouldReceive('as')->andReturnSelf();
$pending->shouldReceive('with')->andReturnSelf();
$pending->shouldReceive('sendNow')->andThrow(new BroadcastException('Payload too large'));

Broadcast::shouldReceive('on')->andReturn($pending);

$received = null;

$job = new BroadcastAgent(
agent: new AssistantAgent,
prompt: 'Say hello',
channels: new Channel('test-channel'),
);

$job->then(function ($response) use (&$received) {
$received = $response;
});

$job->handle();

expect($received)->not->toBeNull('then() callback was never invoked despite a failed broadcast')
->toBeInstanceOf(StreamedAgentResponse::class)
->and($received->text)->toBe('Hello world');
});

test('streamed response passed to then is fully resolved', function () {
Event::fake();
AssistantAgent::fake(['Hello world']);
Expand Down
81 changes: 81 additions & 0 deletions tests/Feature/WithoutBroadcastingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

use Illuminate\Broadcasting\AnonymousEvent;
use Illuminate\Broadcasting\Channel;
use Illuminate\Support\Facades\Event;
use Laravel\Ai\Attributes\WithoutBroadcasting;
use Laravel\Ai\Jobs\BroadcastAgent;
use Laravel\Ai\Streaming\Events\TextDelta;
use Laravel\Ai\Streaming\Events\ToolCall;
use Laravel\Ai\Streaming\Events\ToolResult;
use Tests\Fixtures\Agents\AssistantAgent;
use Tests\Fixtures\Agents\NonBroadcastingTextAgent;
use Tests\Fixtures\Agents\NonBroadcastingToolAgent;

test('it rejects event classes that are not stream events', function () {
expect(fn () => new WithoutBroadcasting('App\Events\Typo'))
->toThrow(InvalidArgumentException::class);
});

test('no events are withheld when the attribute is absent', function () {
expect(WithoutBroadcasting::eventsFor(new AssistantAgent))->toBe([]);
});

test('listed events are withheld when the attribute is present', function () {
expect(WithoutBroadcasting::eventsFor(new NonBroadcastingToolAgent))
->toContain(ToolResult::class)
->toContain(ToolCall::class);
});

test('events not listed in the attribute are not withheld', function () {
expect(WithoutBroadcasting::eventsFor(new NonBroadcastingToolAgent))
->not->toContain(TextDelta::class);
});

test('a null target withholds nothing', function () {
expect(WithoutBroadcasting::eventsFor(null))->toBe([]);
});

test('broadcast withholds listed events from the channel but assembles the full response', function () {
Event::fake();
NonBroadcastingTextAgent::fake(['Hello world']);

$received = null;

(new NonBroadcastingTextAgent)
->broadcastNow('Say hello', new Channel('test-channel'))
->then(function ($response) use (&$received) {
$received = $response;
});

Event::assertNotDispatched(AnonymousEvent::class, fn (AnonymousEvent $e) => $e->broadcastAs() === 'text_delta');
Event::assertDispatched(AnonymousEvent::class, fn (AnonymousEvent $e) => $e->broadcastAs() === 'stream_start');

expect($received)->not->toBeNull('then() callback was never invoked')
->and($received->text)->toBe('Hello world');
});

test('broadcast agent withholds listed events from the channel but resolves the full response', function () {
Event::fake();
NonBroadcastingTextAgent::fake(['Hello world']);

$received = null;

$job = new BroadcastAgent(
agent: new NonBroadcastingTextAgent,
prompt: 'Say hello',
channels: new Channel('test-channel'),
);

$job->then(function ($response) use (&$received) {
$received = $response;
});

$job->handle();

Event::assertNotDispatched(AnonymousEvent::class, fn (AnonymousEvent $e) => $e->broadcastAs() === 'text_delta');
Event::assertDispatched(AnonymousEvent::class, fn (AnonymousEvent $e) => $e->broadcastAs() === 'stream_start');

expect($received)->not->toBeNull('then() callback was never invoked')
->and($received->text)->toBe('Hello world');
});
19 changes: 19 additions & 0 deletions tests/Fixtures/Agents/NonBroadcastingTextAgent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace Tests\Fixtures\Agents;

use Laravel\Ai\Attributes\WithoutBroadcasting;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Promptable;
use Laravel\Ai\Streaming\Events\TextDelta;

#[WithoutBroadcasting(TextDelta::class)]
class NonBroadcastingTextAgent implements Agent
{
use Promptable;

public function instructions(): string
{
return 'You are a helpful assistant.';
}
}
20 changes: 20 additions & 0 deletions tests/Fixtures/Agents/NonBroadcastingToolAgent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace Tests\Fixtures\Agents;

use Laravel\Ai\Attributes\WithoutBroadcasting;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Promptable;
use Laravel\Ai\Streaming\Events\ToolCall;
use Laravel\Ai\Streaming\Events\ToolResult;

#[WithoutBroadcasting(ToolResult::class, ToolCall::class)]
class NonBroadcastingToolAgent implements Agent
{
use Promptable;

public function instructions(): string
{
return 'You are a helpful assistant.';
}
}