Skip to content

Commit 10b13d3

Browse files
committed
feature #575 [Platform] Add event system for string input conversion (RamyHakam)
This PR was squashed before being merged into the main branch. Discussion ---------- [Platform] Add event system for string input conversion | Q | A | ------------- | --- | Bug fix? | yes | New feature? | yes | Docs? | no | Issues | Fix #326 | License | MIT ## Description Implements event-based input processing to resolve fatal errors when string payloads are passed to ModelClient implementations using `array_merge($payload, $options)`. **Solution:** - `PlatformInvokationEvent`: Dispatched before platform invocation - `StringToMessageBagListener`: Converts strings to MessageBag for models with INPUT_MESSAGES capability - Enables simple usage: `$platform->invoke($model, 'Hello')` **Backward Compatible:** EventDispatcher is optional, existing code unchanged. Commits ------- c5e362d [Platform] Add event system for string input conversion
2 parents 71465dc + c5e362d commit 10b13d3

File tree

6 files changed

+285
-4
lines changed

6 files changed

+285
-4
lines changed

src/platform/composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"phpstan/phpdoc-parser": "^2.1",
4949
"psr/log": "^3.0",
5050
"symfony/clock": "^7.3|^8.0",
51+
"symfony/event-dispatcher": "^7.3|^8.0",
5152
"symfony/http-client": "^7.3|^8.0",
5253
"symfony/property-access": "^7.3|^8.0",
5354
"symfony/property-info": "^7.3|^8.0",
@@ -66,7 +67,6 @@
6667
"symfony/ai-agent": "@dev",
6768
"symfony/console": "^7.3|^8.0",
6869
"symfony/dotenv": "^7.3|^8.0",
69-
"symfony/event-dispatcher": "^7.3|^8.0",
7070
"symfony/finder": "^7.3|^8.0",
7171
"symfony/process": "^7.3|^8.0",
7272
"symfony/var-dumper": "^7.3|^8.0"
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Event;
13+
14+
use Symfony\AI\Platform\Model;
15+
use Symfony\Contracts\EventDispatcher\Event;
16+
17+
/**
18+
* Event dispatched before platform invocation to allow modification of input data.
19+
*
20+
* @author Ramy Hakam <pencilsoft1@gmail.com>
21+
*/
22+
final class InvocationEvent extends Event
23+
{
24+
/**
25+
* @param array<string, mixed>|string|object $input
26+
* @param array<string, mixed> $options
27+
*/
28+
public function __construct(
29+
private Model $model,
30+
private array|string|object $input,
31+
private array $options = [],
32+
) {
33+
}
34+
35+
public function getModel(): Model
36+
{
37+
return $this->model;
38+
}
39+
40+
public function setModel(Model $model): void
41+
{
42+
$this->model = $model;
43+
}
44+
45+
/**
46+
* @return array<string, mixed>|string|object
47+
*/
48+
public function getInput(): array|string|object
49+
{
50+
return $this->input;
51+
}
52+
53+
/**
54+
* @param array<string, mixed>|string|object $input
55+
*/
56+
public function setInput(array|string|object $input): void
57+
{
58+
$this->input = $input;
59+
}
60+
61+
/**
62+
* @return array<string, mixed>
63+
*/
64+
public function getOptions(): array
65+
{
66+
return $this->options;
67+
}
68+
69+
/**
70+
* @param array<string, mixed> $options
71+
*/
72+
public function setOptions(array $options): void
73+
{
74+
$this->options = $options;
75+
}
76+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\EventListener;
13+
14+
use Symfony\AI\Platform\Capability;
15+
use Symfony\AI\Platform\Event\InvocationEvent;
16+
use Symfony\AI\Platform\Message\Message;
17+
use Symfony\AI\Platform\Message\MessageBag;
18+
19+
/**
20+
* Converts string inputs to MessageBag for models that support INPUT_MESSAGES capability.
21+
*
22+
* @author Ramy Hakam <pencilsoft1@gmail.com>
23+
*/
24+
final class StringToMessageBagListener
25+
{
26+
public function __invoke(InvocationEvent $event): void
27+
{
28+
// Only process string inputs
29+
if (!\is_string($event->getInput())) {
30+
return;
31+
}
32+
33+
// Only process models that support INPUT_MESSAGES capability
34+
if (!$event->getModel()->supports(Capability::INPUT_MESSAGES)) {
35+
return;
36+
}
37+
38+
// Convert string to MessageBag with a user message
39+
$event->setInput(new MessageBag(Message::ofUser($event->getInput())));
40+
}
41+
}

src/platform/src/Platform.php

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
namespace Symfony\AI\Platform;
1313

14+
use Psr\EventDispatcher\EventDispatcherInterface;
15+
use Symfony\AI\Platform\Event\InvocationEvent;
1416
use Symfony\AI\Platform\Exception\RuntimeException;
1517
use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface;
1618
use Symfony\AI\Platform\Result\DeferredResult;
@@ -38,8 +40,9 @@ final class Platform implements PlatformInterface
3840
public function __construct(
3941
iterable $modelClients,
4042
iterable $resultConverters,
41-
private ModelCatalogInterface $modelCatalog,
43+
private readonly ModelCatalogInterface $modelCatalog,
4244
private ?Contract $contract = null,
45+
private readonly ?EventDispatcherInterface $eventDispatcher = null,
4346
) {
4447
$this->contract = $contract ?? Contract::create();
4548
$this->modelClients = $modelClients instanceof \Traversable ? iterator_to_array($modelClients) : $modelClients;
@@ -49,8 +52,12 @@ public function __construct(
4952
public function invoke(string $model, array|string|object $input, array $options = []): DeferredResult
5053
{
5154
$model = $this->modelCatalog->getModel($model);
52-
$payload = $this->contract->createRequestPayload($model, $input);
53-
$options = array_merge($model->getOptions(), $options);
55+
56+
$event = new InvocationEvent($model, $input, $options);
57+
$this->eventDispatcher?->dispatch($event);
58+
59+
$payload = $this->contract->createRequestPayload($event->getModel(), $event->getInput());
60+
$options = array_merge($model->getOptions(), $event->getOptions());
5461

5562
if (isset($options['tools'])) {
5663
$options['tools'] = $this->contract->createToolOption($options['tools'], $model);
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Tests\Event;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\AI\Platform\Capability;
16+
use Symfony\AI\Platform\Event\InvocationEvent;
17+
use Symfony\AI\Platform\Message\Message;
18+
use Symfony\AI\Platform\Message\MessageBag;
19+
use Symfony\AI\Platform\Model;
20+
21+
final class InvocationEventTest extends TestCase
22+
{
23+
public function testGettersReturnCorrectValues()
24+
{
25+
$model = new class('test-model', [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT]) extends Model {
26+
};
27+
28+
$input = 'Hello, world!';
29+
$options = ['temperature' => 0.7];
30+
31+
$event = new InvocationEvent($model, $input, $options);
32+
33+
$this->assertSame($model, $event->getModel());
34+
$this->assertSame($input, $event->getInput());
35+
$this->assertSame($options, $event->getOptions());
36+
}
37+
38+
public function testSetInputChangesInput()
39+
{
40+
$model = new class('test-model', [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT]) extends Model {
41+
};
42+
43+
$originalInput = 'Hello, world!';
44+
$newInput = new MessageBag(Message::ofUser('Hello, world!'));
45+
46+
$event = new InvocationEvent($model, $originalInput);
47+
$event->setInput($newInput);
48+
49+
$this->assertSame($newInput, $event->getInput());
50+
}
51+
52+
public function testWorksWithDifferentInputTypes()
53+
{
54+
$model = new class('test-model', [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT]) extends Model {
55+
};
56+
57+
// Test with string
58+
$stringEvent = new InvocationEvent($model, 'string input');
59+
$this->assertIsString($stringEvent->getInput());
60+
61+
// Test with array
62+
$arrayEvent = new InvocationEvent($model, ['key' => 'value']);
63+
$this->assertIsArray($arrayEvent->getInput());
64+
65+
// Test with object
66+
$objectInput = new MessageBag();
67+
$objectEvent = new InvocationEvent($model, $objectInput);
68+
$this->assertSame($objectInput, $objectEvent->getInput());
69+
}
70+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Tests\EventListener;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\AI\Platform\Capability;
16+
use Symfony\AI\Platform\Event\InvocationEvent;
17+
use Symfony\AI\Platform\EventListener\StringToMessageBagListener;
18+
use Symfony\AI\Platform\Message\Content\Text;
19+
use Symfony\AI\Platform\Message\Message;
20+
use Symfony\AI\Platform\Message\MessageBag;
21+
use Symfony\AI\Platform\Message\UserMessage;
22+
use Symfony\AI\Platform\Model;
23+
24+
final class StringToMessageBagListenerTest extends TestCase
25+
{
26+
public function testConvertsStringInputToMessageBagForMessagesCapableModel()
27+
{
28+
$model = new class('test-model', [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT]) extends Model {
29+
};
30+
31+
$event = new InvocationEvent($model, 'Hello, world!');
32+
$listener = new StringToMessageBagListener();
33+
34+
$listener($event);
35+
36+
$this->assertInstanceOf(MessageBag::class, $event->getInput());
37+
$this->assertCount(1, $event->getInput()->getMessages());
38+
$message = $event->getInput()->getMessages()[0];
39+
$this->assertInstanceOf(UserMessage::class, $message);
40+
$this->assertCount(1, $message->getContent());
41+
$content = $message->getContent()[0];
42+
$this->assertInstanceOf(Text::class, $content);
43+
$this->assertSame('Hello, world!', $content->getText());
44+
}
45+
46+
public function testDoesNotConvertStringInputForNonMessagesCapableModel()
47+
{
48+
$model = new class('test-model', [Capability::INPUT_TEXT, Capability::OUTPUT_TEXT]) extends Model {
49+
};
50+
51+
$originalInput = 'Hello, world!';
52+
$event = new InvocationEvent($model, $originalInput);
53+
$listener = new StringToMessageBagListener();
54+
55+
$listener($event);
56+
57+
$this->assertSame($originalInput, $event->getInput());
58+
}
59+
60+
public function testDoesNotConvertNonStringInput()
61+
{
62+
$model = new class('test-model', [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT]) extends Model {
63+
};
64+
65+
$originalInput = new MessageBag(Message::ofUser('Hello'));
66+
$event = new InvocationEvent($model, $originalInput);
67+
$listener = new StringToMessageBagListener();
68+
69+
$listener($event);
70+
71+
$this->assertSame($originalInput, $event->getInput());
72+
}
73+
74+
public function testDoesNotConvertArrayInput()
75+
{
76+
$model = new class('test-model', [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT]) extends Model {
77+
};
78+
79+
$originalInput = ['key' => 'value'];
80+
$event = new InvocationEvent($model, $originalInput);
81+
$listener = new StringToMessageBagListener();
82+
83+
$listener($event);
84+
85+
$this->assertSame($originalInput, $event->getInput());
86+
}
87+
}

0 commit comments

Comments
 (0)