feat: add AttachmentCollector request-scoped side-channel for tools
This commit is contained in:
parent
6007aec17d
commit
93a84f8f95
3 changed files with 88 additions and 0 deletions
44
app/Ai/Tools/AttachmentCollector.php
Normal file
44
app/Ai/Tools/AttachmentCollector.php
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
41
tests/Feature/Ai/Tools/AttachmentCollectorTest.php
Normal file
41
tests/Feature/Ai/Tools/AttachmentCollectorTest.php
Normal 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');
|
||||
});
|
||||
Loading…
Reference in a new issue