feat: add AttachmentCollector request-scoped side-channel for tools

This commit is contained in:
Paulo Castellano 2026-04-16 09:52:41 -03:00
parent 6007aec17d
commit 93a84f8f95
3 changed files with 88 additions and 0 deletions

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Ai\Tools;
/**
* Request-scoped side-channel for tools to surface structured attachments.
*
* Laravel AI SDK tools return Stringable|string to the LLM, so they can't
* pass structured data back to the controller directly. This collector is
* registered as a scoped singleton — the controller clears it before the
* agent prompt, tools push attachments into it during execution, and the
* controller reads $collector->all() after the agent finishes to persist
* them onto the resulting AiMessage.
*/
class AttachmentCollector
{
/**
* @var array<int, array<string, mixed>>
*/
private array $attachments = [];
/**
* @param array<string, mixed> $attachment
*/
public function push(array $attachment): void
{
$this->attachments[] = $attachment;
}
/**
* @return array<int, array<string, mixed>>
*/
public function all(): array
{
return $this->attachments;
}
public function clear(): void
{
$this->attachments = [];
}
}

View file

@ -5,6 +5,7 @@
namespace App\Providers;
use App\Ai\Providers\ExtendedGeminiProvider;
use App\Ai\Tools\AttachmentCollector;
use App\Listeners\StripeEventListener;
use App\Models\Account;
use App\Models\AiMessage;
@ -80,6 +81,8 @@ public function register(): void
default => new GeminiTextGenerationService,
},
);
$this->app->scoped(AttachmentCollector::class);
}
/**

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use App\Ai\Tools\AttachmentCollector;
test('collector starts empty', function () {
$collector = new AttachmentCollector;
expect($collector->all())->toBeEmpty();
});
test('collector records attachments in push order', function () {
$collector = new AttachmentCollector;
$collector->push(['id' => 'a', 'type' => 'image']);
$collector->push(['id' => 'b', 'type' => 'video']);
expect($collector->all())->toHaveCount(2);
expect($collector->all()[0]['id'])->toBe('a');
expect($collector->all()[1]['id'])->toBe('b');
});
test('collector clear removes all attachments', function () {
$collector = new AttachmentCollector;
$collector->push(['id' => 'a', 'type' => 'image']);
$collector->clear();
expect($collector->all())->toBeEmpty();
});
test('collector resolves as a scoped singleton within the container', function () {
$a = app(AttachmentCollector::class);
$b = app(AttachmentCollector::class);
$a->push(['id' => 'same', 'type' => 'image']);
expect($b->all())->toHaveCount(1);
expect($b->all()[0]['id'])->toBe('same');
});