diff --git a/demo/config/packages/ai.yaml b/demo/config/packages/ai.yaml index 494bb029e..aa356ff62 100644 --- a/demo/config/packages/ai.yaml +++ b/demo/config/packages/ai.yaml @@ -68,6 +68,22 @@ ai: - 'Symfony\AI\Store\Document\Transformer\TextTrimTransformer' vectorizer: 'ai.vectorizer.openai' store: 'ai.store.chroma_db.symfonycon' + message_store: + cache: + audio: + service: 'cache.app' + wikipedia: ~ + youtube: ~ + chat: + audio: + agent: 'audio' + message_store: 'cache.audio' + wikipedia: + agent: 'wikipedia' + message_store: 'cache.wikipedia' + youtube: + agent: 'youtube' + message_store: 'cache.youtube' services: _defaults: diff --git a/demo/src/Audio/Chat.php b/demo/src/Audio/Chat.php deleted file mode 100644 index 178dd32fa..000000000 --- a/demo/src/Audio/Chat.php +++ /dev/null @@ -1,75 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace App\Audio; - -use Symfony\AI\Agent\AgentInterface; -use Symfony\AI\Platform\Bridge\OpenAi\Whisper; -use Symfony\AI\Platform\Message\Content\Audio; -use Symfony\AI\Platform\Message\Message; -use Symfony\AI\Platform\Message\MessageBag; -use Symfony\AI\Platform\PlatformInterface; -use Symfony\AI\Platform\Result\TextResult; -use Symfony\Component\DependencyInjection\Attribute\Autowire; -use Symfony\Component\HttpFoundation\RequestStack; - -final class Chat -{ - private const SESSION_KEY = 'audio-chat'; - - public function __construct( - private readonly PlatformInterface $platform, - private readonly RequestStack $requestStack, - #[Autowire(service: 'ai.agent.audio')] - private readonly AgentInterface $agent, - ) { - } - - public function say(string $base64audio): void - { - // Convert base64 to temporary binary file - $path = tempnam(sys_get_temp_dir(), 'audio-').'.wav'; - file_put_contents($path, base64_decode($base64audio)); - - $result = $this->platform->invoke(new Whisper(), Audio::fromFile($path)); - - $this->submitMessage($result->asText()); - } - - public function loadMessages(): MessageBag - { - return $this->requestStack->getSession()->get(self::SESSION_KEY, new MessageBag()); - } - - public function submitMessage(string $message): void - { - $messages = $this->loadMessages(); - - $messages->add(Message::ofUser($message)); - $result = $this->agent->call($messages); - - \assert($result instanceof TextResult); - - $messages->add(Message::ofAssistant($result->getContent())); - - $this->saveMessages($messages); - } - - public function reset(): void - { - $this->requestStack->getSession()->remove(self::SESSION_KEY); - } - - private function saveMessages(MessageBag $messages): void - { - $this->requestStack->getSession()->set(self::SESSION_KEY, $messages); - } -} diff --git a/demo/src/Audio/TwigComponent.php b/demo/src/Audio/TwigComponent.php index dbb33a1be..61ac925d4 100644 --- a/demo/src/Audio/TwigComponent.php +++ b/demo/src/Audio/TwigComponent.php @@ -11,7 +11,16 @@ namespace App\Audio; +use Symfony\AI\Agent\AgentInterface; +use Symfony\AI\Agent\Chat\MessageStoreInterface; +use Symfony\AI\Agent\ChatInterface; +use Symfony\AI\Platform\Bridge\OpenAi\Whisper; +use Symfony\AI\Platform\Message\Content\Audio; +use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageInterface; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveAction; use Symfony\UX\LiveComponent\Attribute\LiveArg; @@ -23,7 +32,14 @@ final class TwigComponent use DefaultActionTrait; public function __construct( - private readonly Chat $chat, + private readonly PlatformInterface $platform, + private readonly RequestStack $requestStack, + #[Autowire(service: 'ai.agent.audio')] + private readonly AgentInterface $agent, + #[Autowire(service: 'ai.chat.audio')] + private readonly ChatInterface $chat, + #[Autowire(service: 'ai.message_store.cache.audio')] + private readonly MessageStoreInterface $messageStore, ) { } @@ -32,18 +48,24 @@ public function __construct( */ public function getMessages(): array { - return $this->chat->loadMessages()->withoutSystemMessage()->getMessages(); + return $this->chat->getCurrentMessageBag()->withoutSystemMessage()->getMessages(); } #[LiveAction] public function submit(#[LiveArg] string $audio): void { - $this->chat->say($audio); + // Convert base64 to temporary binary file + $path = tempnam(sys_get_temp_dir(), 'audio-').'.wav'; + file_put_contents($path, base64_decode($audio)); + + $result = $this->platform->invoke(new Whisper(), Audio::fromFile($path)); + + $this->chat->submit(Message::ofUser($result->asText())); } #[LiveAction] public function reset(): void { - $this->chat->reset(); + $this->messageStore->clear(); } } diff --git a/demo/src/Wikipedia/Chat.php b/demo/src/Wikipedia/Chat.php deleted file mode 100644 index cd9736f7a..000000000 --- a/demo/src/Wikipedia/Chat.php +++ /dev/null @@ -1,60 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace App\Wikipedia; - -use Symfony\AI\Agent\AgentInterface; -use Symfony\AI\Platform\Message\Message; -use Symfony\AI\Platform\Message\MessageBag; -use Symfony\AI\Platform\Result\TextResult; -use Symfony\Component\DependencyInjection\Attribute\Autowire; -use Symfony\Component\HttpFoundation\RequestStack; - -final class Chat -{ - private const SESSION_KEY = 'wikipedia-chat'; - - public function __construct( - private readonly RequestStack $requestStack, - #[Autowire(service: 'ai.agent.wikipedia')] - private readonly AgentInterface $agent, - ) { - } - - public function loadMessages(): MessageBag - { - return $this->requestStack->getSession()->get(self::SESSION_KEY, new MessageBag()); - } - - public function submitMessage(string $message): void - { - $messages = $this->loadMessages(); - - $messages->add(Message::ofUser($message)); - $result = $this->agent->call($messages); - - \assert($result instanceof TextResult); - - $messages->add(Message::ofAssistant($result->getContent())); - - $this->saveMessages($messages); - } - - public function reset(): void - { - $this->requestStack->getSession()->remove(self::SESSION_KEY); - } - - private function saveMessages(MessageBag $messages): void - { - $this->requestStack->getSession()->set(self::SESSION_KEY, $messages); - } -} diff --git a/demo/src/Wikipedia/TwigComponent.php b/demo/src/Wikipedia/TwigComponent.php index d0f4897e9..0170428b6 100644 --- a/demo/src/Wikipedia/TwigComponent.php +++ b/demo/src/Wikipedia/TwigComponent.php @@ -11,7 +11,11 @@ namespace App\Wikipedia; +use Symfony\AI\Agent\Chat\MessageStoreInterface; +use Symfony\AI\Agent\ChatInterface; +use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveAction; use Symfony\UX\LiveComponent\Attribute\LiveArg; @@ -23,7 +27,10 @@ final class TwigComponent use DefaultActionTrait; public function __construct( - private readonly Chat $wikipedia, + #[Autowire(service: 'ai.chat.wikipedia')] + private readonly ChatInterface $chat, + #[Autowire(service: 'ai.message_store.cache.wikipedia')] + private readonly MessageStoreInterface $messageStore, ) { } @@ -32,18 +39,18 @@ public function __construct( */ public function getMessages(): array { - return $this->wikipedia->loadMessages()->withoutSystemMessage()->getMessages(); + return $this->chat->getCurrentMessageBag()->getMessages(); } #[LiveAction] public function submit(#[LiveArg] string $message): void { - $this->wikipedia->submitMessage($message); + $this->chat->submit(Message::ofUser($message)); } #[LiveAction] public function reset(): void { - $this->wikipedia->reset(); + $this->messageStore->clear(); } } diff --git a/demo/src/YouTube/Chat.php b/demo/src/YouTube/Chat.php deleted file mode 100644 index 515da3ff7..000000000 --- a/demo/src/YouTube/Chat.php +++ /dev/null @@ -1,82 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace App\YouTube; - -use Symfony\AI\Agent\AgentInterface; -use Symfony\AI\Platform\Message\Message; -use Symfony\AI\Platform\Message\MessageBag; -use Symfony\AI\Platform\Result\TextResult; -use Symfony\Component\DependencyInjection\Attribute\Autowire; -use Symfony\Component\HttpFoundation\RequestStack; - -final class Chat -{ - private const SESSION_KEY = 'youtube-chat'; - - public function __construct( - private readonly RequestStack $requestStack, - #[Autowire(service: 'ai.agent.youtube')] - private readonly AgentInterface $agent, - private readonly TranscriptFetcher $transcriptFetcher, - ) { - } - - public function loadMessages(): MessageBag - { - return $this->requestStack->getSession()->get(self::SESSION_KEY, new MessageBag()); - } - - public function start(string $videoId): void - { - $transcript = $this->transcriptFetcher->fetchTranscript($videoId); - $system = <<reset(); - $this->saveMessages($messages); - } - - public function submitMessage(string $message): void - { - $messages = $this->loadMessages(); - - $messages->add(Message::ofUser($message)); - $result = $this->agent->call($messages); - - \assert($result instanceof TextResult); - - $messages->add(Message::ofAssistant($result->getContent())); - - $this->saveMessages($messages); - } - - public function reset(): void - { - $this->requestStack->getSession()->remove(self::SESSION_KEY); - } - - private function saveMessages(MessageBag $messages): void - { - $this->requestStack->getSession()->set(self::SESSION_KEY, $messages); - } -} diff --git a/demo/src/YouTube/TwigComponent.php b/demo/src/YouTube/TwigComponent.php index 6675abd21..89a04016e 100644 --- a/demo/src/YouTube/TwigComponent.php +++ b/demo/src/YouTube/TwigComponent.php @@ -12,7 +12,12 @@ namespace App\YouTube; use Psr\Log\LoggerInterface; +use Symfony\AI\Agent\Chat\MessageStoreInterface; +use Symfony\AI\Agent\ChatInterface; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; use Symfony\AI\Platform\Message\MessageInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveAction; use Symfony\UX\LiveComponent\Attribute\LiveArg; @@ -26,8 +31,12 @@ final class TwigComponent use DefaultActionTrait; public function __construct( - private readonly Chat $youTube, private readonly LoggerInterface $logger, + #[Autowire(service: 'ai.chat.youtube')] + private readonly ChatInterface $chat, + #[Autowire(service: 'ai.message_store.cache.youtube')] + private readonly MessageStoreInterface $messageStore, + private readonly TranscriptFetcher $transcriptFetcher, ) { } @@ -39,10 +48,10 @@ public function start(#[LiveArg] string $videoId): void } try { - $this->youTube->start($videoId); + $this->doStart($videoId); } catch (\Exception $e) { $this->logger->error('Unable to start YouTube chat.', ['exception' => $e]); - $this->youTube->reset(); + $this->messageStore->clear(); } } @@ -51,19 +60,19 @@ public function start(#[LiveArg] string $videoId): void */ public function getMessages(): array { - return $this->youTube->loadMessages()->withoutSystemMessage()->getMessages(); + return $this->chat->getCurrentMessageBag()->withoutSystemMessage()->getMessages(); } #[LiveAction] public function submit(#[LiveArg] string $message): void { - $this->youTube->submitMessage($message); + $this->chat->submit(Message::ofUser($message)); } #[LiveAction] public function reset(): void { - $this->youTube->reset(); + $this->messageStore->clear(); } private function getVideoIdFromUrl(string $url): string @@ -76,4 +85,25 @@ private function getVideoIdFromUrl(string $url): string return u($query)->after('v=')->before('&')->toString(); } + + private function doStart(string $videoId): void + { + $transcript = $this->transcriptFetcher->fetchTranscript($videoId); + $system = <<reset(); + $this->chat->initiate($messages); + } } diff --git a/examples/composer.json b/examples/composer.json index d3a4ebda1..0b338a6d1 100644 --- a/examples/composer.json +++ b/examples/composer.json @@ -17,6 +17,7 @@ "probots-io/pinecone-php": "^1.0", "psr/http-factory-implementation": "*", "symfony/ai-agent": "@dev", + "symfony/ai-chat": "@dev", "symfony/ai-platform": "@dev", "symfony/ai-store": "@dev", "symfony/cache": "^7.3|^8.0", diff --git a/examples/misc/persistent-chat-double-agent.php b/examples/misc/persistent-chat-double-agent.php new file mode 100644 index 000000000..2be31bf06 --- /dev/null +++ b/examples/misc/persistent-chat-double-agent.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Chat\Bridge\Local\InMemoryStore; +use Symfony\AI\Chat\Chat; +use Symfony\AI\Platform\Bridge\OpenAi\Gpt; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); +$llm = new Gpt(Gpt::GPT_4O_MINI); + +$agent = new Agent($platform, $llm, logger: logger()); + +$store = new InMemoryStore(); + +$chat = new Chat($agent, $store); + +$chat->initiate(new MessageBag( + Message::forSystem('You are a helpful assistant. You only answer with short sentences.'), +)); + +$forkedChat = $chat->fork('fork'); + +$chat->submit(Message::ofUser('My name is Christopher.')); +$firstChatMessage = $chat->submit(Message::ofUser('What is my name?')); + +$forkedChat->submit(Message::ofUser('My name is William.')); +$secondChatMessage = $forkedChat->submit(Message::ofUser('What is my name?')); + +$firstChatMessageContent = $firstChatMessage->content; +$secondChatMessageContent = $secondChatMessage->content; + +echo $firstChatMessageContent.\PHP_EOL; +echo $secondChatMessageContent.\PHP_EOL; + +assert(str_contains($firstChatMessageContent, 'Christopher')); +assert(!str_contains($secondChatMessageContent, 'Christopher')); diff --git a/examples/misc/persistent-chat.php b/examples/misc/persistent-chat.php index 8b9df26fa..1ca812cf6 100644 --- a/examples/misc/persistent-chat.php +++ b/examples/misc/persistent-chat.php @@ -10,8 +10,8 @@ */ use Symfony\AI\Agent\Agent; -use Symfony\AI\Agent\Chat; -use Symfony\AI\Agent\Chat\MessageStore\InMemoryStore; +use Symfony\AI\Chat\Bridge\Local\InMemoryStore; +use Symfony\AI\Chat\Chat; use Symfony\AI\Platform\Bridge\OpenAi\Gpt; use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; use Symfony\AI\Platform\Message\Message; diff --git a/src/agent/composer.json b/src/agent/composer.json index 43d5cb4bb..bd566f2d9 100644 --- a/src/agent/composer.json +++ b/src/agent/composer.json @@ -24,6 +24,7 @@ "phpdocumentor/reflection-docblock": "^5.4", "phpstan/phpdoc-parser": "^2.1", "psr/log": "^3.0", + "symfony/ai-chat": "@dev", "symfony/ai-platform": "@dev", "symfony/clock": "^7.3|^8.0", "symfony/http-client": "^7.3|^8.0", diff --git a/src/agent/src/Chat/MessageStore/InMemoryStore.php b/src/agent/src/Chat/MessageStore/InMemoryStore.php deleted file mode 100644 index 41e01ed1e..000000000 --- a/src/agent/src/Chat/MessageStore/InMemoryStore.php +++ /dev/null @@ -1,38 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\Agent\Chat\MessageStore; - -use Symfony\AI\Agent\Chat\MessageStoreInterface; -use Symfony\AI\Platform\Message\MessageBag; - -/** - * @author Christopher Hertel - */ -final class InMemoryStore implements MessageStoreInterface -{ - private MessageBag $messages; - - public function save(MessageBag $messages): void - { - $this->messages = $messages; - } - - public function load(): MessageBag - { - return $this->messages ?? new MessageBag(); - } - - public function clear(): void - { - $this->messages = new MessageBag(); - } -} diff --git a/src/agent/tests/AgentTest.php b/src/agent/tests/AgentTest.php index 06d4b8e34..579807fd2 100644 --- a/src/agent/tests/AgentTest.php +++ b/src/agent/tests/AgentTest.php @@ -19,6 +19,8 @@ use Symfony\AI\Agent\Agent; use Symfony\AI\Agent\AgentAwareInterface; use Symfony\AI\Agent\AgentInterface; +use Symfony\AI\Agent\Chat; +use Symfony\AI\Agent\Chat\MessageStore\InMemoryStore; use Symfony\AI\Agent\Exception\InvalidArgumentException; use Symfony\AI\Agent\Exception\MissingModelSupportException; use Symfony\AI\Agent\Exception\RuntimeException; @@ -30,6 +32,7 @@ use Symfony\AI\Platform\Message\Content\Audio; use Symfony\AI\Platform\Message\Content\Image; use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; use Symfony\AI\Platform\Message\UserMessage; use Symfony\AI\Platform\Model; @@ -37,6 +40,7 @@ use Symfony\AI\Platform\Result\RawResultInterface; use Symfony\AI\Platform\Result\ResultInterface; use Symfony\AI\Platform\Result\ResultPromise; +use Symfony\AI\Platform\Result\TextResult; use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface; use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponseInterface; @@ -49,6 +53,8 @@ #[UsesClass(Text::class)] #[UsesClass(Audio::class)] #[UsesClass(Image::class)] +#[UsesClass(InMemoryStore::class)] +#[UsesClass(Chat::class)] #[Small] final class AgentTest extends TestCase { @@ -426,4 +432,35 @@ public function testGetNameReturnsProvidedName() $this->assertSame($name, $agent->getName()); } + + public function testMultipleAgentCanUseSameChat() + { + $platform = $this->createMock(PlatformInterface::class); + $platform->method('invoke') + ->willReturn(new ResultPromise(static fn (): TextResult => new TextResult('Assistant response'), $this->createStub(RawResultInterface::class))); + + $model = $this->createMock(Model::class); + + $firstAgent = new Agent($platform, $model); + $secondAgent = new Agent($platform, $model); + + $store = new InMemoryStore(); + + $firstChat = new Chat($firstAgent, $store); + $secondChat = new Chat($secondAgent, $store); + + $firstChat->initiate(new MessageBag( + Message::forSystem('You are a helpful assistant. You only answer with short sentences.'), + )); + $secondChat->initiate(new MessageBag( + Message::forSystem('You are a helpful assistant. You only answer with short sentences.'), + )); + + $firstChat->submit(new UserMessage(new Text('Hello'))); + $secondChat->submit(new UserMessage(new Text('Hello'))); + $secondChat->submit(new UserMessage(new Text('Hello there'))); + + $this->assertCount(3, $store->load('foo')); + $this->assertCount(5, $store->load('bar')); + } } diff --git a/src/ai-bundle/composer.json b/src/ai-bundle/composer.json index 9ec551bad..8c70b3ca5 100644 --- a/src/ai-bundle/composer.json +++ b/src/ai-bundle/composer.json @@ -16,6 +16,7 @@ "require": { "php": ">=8.2", "symfony/ai-agent": "@dev", + "symfony/ai-chat": "@dev", "symfony/ai-platform": "@dev", "symfony/ai-store": "@dev", "symfony/config": "^7.3|^8.0", diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index 795a4d49b..9a73d0317 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -585,6 +585,55 @@ ->end() ->end() ->end() + ->arrayNode('message_store') + ->children() + ->arrayNode('cache') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('service')->cannotBeEmpty()->defaultValue('cache.app')->end() + ->end() + ->children() + ->scalarNode('identifier')->end() + ->end() + ->children() + ->scalarNode('ttl')->end() + ->end() + ->end() + ->end() + ->arrayNode('memory') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('identifier')->end() + ->end() + ->end() + ->end() + ->arrayNode('session') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('identifier')->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->arrayNode('chat') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('agent') + ->isRequired() + ->info('Name of the agent used for the chat') + ->end() + ->scalarNode('message_store') + ->isRequired() + ->info('Name of the message store (example: "cache.foo" for a message store called "foo" in the "cache" section)') + ->end() + ->end() + ->end() + ->end() ->end() ; }; diff --git a/src/ai-bundle/config/services.php b/src/ai-bundle/config/services.php index 2437ef8d4..644c8987a 100644 --- a/src/ai-bundle/config/services.php +++ b/src/ai-bundle/config/services.php @@ -133,6 +133,8 @@ tagged_iterator('ai.traceable_platform'), service('ai.toolbox'), tagged_iterator('ai.traceable_toolbox'), + tagged_iterator('ai.traceable_message_store'), + tagged_iterator('ai.traceable_chat'), ]) ->tag('data_collector') ->set('ai.traceable_toolbox', TraceableToolbox::class) diff --git a/src/ai-bundle/doc/index.rst b/src/ai-bundle/doc/index.rst index 83e2e5108..64ca7e487 100644 --- a/src/ai-bundle/doc/index.rst +++ b/src/ai-bundle/doc/index.rst @@ -144,6 +144,14 @@ Configuration research: vectorizer: 'ai.vectorizer.mistral_embeddings' store: 'ai.store.memory.research' + message_store: + cache: + main: + service: 'cache.app' + chat: + main: + agent: 'research' + message_store: 'main' Store Dependency Injection -------------------------- diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index cc656361b..0c6318144 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -29,9 +29,17 @@ use Symfony\AI\Agent\Toolbox\ToolFactory\MemoryToolFactory; use Symfony\AI\AiBundle\DependencyInjection\ProcessorCompilerPass; use Symfony\AI\AiBundle\Exception\InvalidArgumentException; +use Symfony\AI\AiBundle\Profiler\TraceableChat; +use Symfony\AI\AiBundle\Profiler\TraceableMessageStore; use Symfony\AI\AiBundle\Profiler\TraceablePlatform; use Symfony\AI\AiBundle\Profiler\TraceableToolbox; use Symfony\AI\AiBundle\Security\Attribute\IsGrantedTool; +use Symfony\AI\Chat\Bridge\Local\CacheStore as CacheMessageStore; +use Symfony\AI\Chat\Bridge\Local\InMemoryStore as InMemoryMessageStore; +use Symfony\AI\Chat\Bridge\Symfony\SessionStore as SessionMessageStore; +use Symfony\AI\Chat\Chat; +use Symfony\AI\Chat\ChatInterface; +use Symfony\AI\Chat\MessageStoreInterface; use Symfony\AI\Platform\Bridge\Anthropic\PlatformFactory as AnthropicPlatformFactory; use Symfony\AI\Platform\Bridge\Azure\OpenAi\PlatformFactory as AzureOpenAiPlatformFactory; use Symfony\AI\Platform\Bridge\Cerebras\PlatformFactory as CerebrasPlatformFactory; @@ -163,6 +171,46 @@ public function loadExtension(array $config, ContainerConfigurator $container, C $builder->setAlias(IndexerInterface::class, 'ai.indexer.'.$indexerName); } + foreach ($config['message_store'] ?? [] as $messageStoreName => $store) { + $this->processMessageStoreConfig($messageStoreName, $store, $builder); + } + + $messageStores = array_keys($builder->findTaggedServiceIds('ai.message_store')); + if (1 === \count($messageStores)) { + $builder->setAlias(MessageStoreInterface::class, reset($messageStores)); + } + + if ($builder->getParameter('kernel.debug')) { + foreach ($messageStores as $messageStore) { + $traceableMessageStoreDefinition = (new Definition(TraceableMessageStore::class)) + ->setDecoratedService($messageStore) + ->setArguments([new Reference('.inner')]) + ->addTag('ai.traceable_message_store'); + $suffix = u($messageStore)->afterLast('.')->toString(); + $builder->setDefinition('ai.traceable_message_store.'.$suffix, $traceableMessageStoreDefinition); + } + } + + foreach ($config['chat'] as $chatName => $chat) { + $this->processChatConfig($chatName, $chat, $builder); + } + + $chats = array_keys($builder->findTaggedServiceIds('ai.chat')); + if (1 === \count($chats)) { + $builder->setAlias(ChatInterface::class, reset($chats)); + } + + if ($builder->getParameter('kernel.debug')) { + foreach ($chats as $chat) { + $traceableChatDefinition = (new Definition(TraceableChat::class)) + ->setDecoratedService($chat) + ->setArguments([new Reference('.inner')]) + ->addTag('ai.traceable_chat'); + $suffix = u($chat)->afterLast('.')->toString(); + $builder->setDefinition('ai.traceable_chat.'.$suffix, $traceableChatDefinition); + } + } + $builder->registerAttributeForAutoconfiguration(AsTool::class, static function (ChildDefinition $definition, AsTool $attribute): void { $definition->addTag('ai.tool', [ 'name' => $attribute->name, @@ -1185,4 +1233,80 @@ private function processIndexerConfig(int|string $name, array $config, Container $container->setDefinition('ai.indexer.'.$name, $definition); } + + /** + * @param array $stores + */ + private function processMessageStoreConfig(string $type, array $stores, ContainerBuilder $container): void + { + if ('cache' === $type) { + foreach ($stores as $name => $store) { + $arguments = [ + new Reference($store['service']), + $store['identifier'] ?? $name, + ]; + + if (\array_key_exists('ttl', $store)) { + $arguments[] = $store['ttl']; + } + + $definition = new Definition(CacheMessageStore::class); + $definition + ->addTag('ai.message_store') + ->setArguments([ + new Reference($store['service']), + $store['identifier'] ?? $name, + ]); + + $container->setDefinition('ai.message_store.'.$type.'.'.$name, $definition); + $container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, (new Target($name.'MessageStore'))->getParsedName()); + } + } + + if ('memory' === $type) { + foreach ($stores as $name => $store) { + $definition = new Definition(InMemoryMessageStore::class); + $definition + ->addTag('ai.message_store') + ->setArguments([ + $store['identifier'] ?? $name, + ]); + + $container->setDefinition('ai.message_store.'.$type.'.'.$name, $definition); + $container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, (new Target($name.'MessageStore'))->getParsedName()); + } + } + + if ('session' === $type) { + foreach ($stores as $name => $store) { + $definition = new Definition(SessionMessageStore::class); + $definition + ->addTag('ai.message_store') + ->setArguments([ + new Reference('request_stack'), + $store['identifier'] ?? $name, + ]); + + $container->setDefinition('ai.message_store.'.$type.'.'.$name, $definition); + $container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, (new Target($name.'MessageStore'))->getParsedName()); + } + } + } + + /** + * @param array $config + */ + private function processChatConfig(int|string $name, array $config, ContainerBuilder $container): void + { + $definition = new Definition(Chat::class); + $definition + ->setArguments([ + new Reference('ai.agent.'.$config['agent']), + new Reference('ai.message_store.'.$config['message_store']), + ]) + ->addTag('ai.chat'); + + $container->setDefinition('ai.chat.'.$name, $definition); + $container->registerAliasForArgument('ai.chat.'.$name, ChatInterface::class, (new Target($name.'Chat'))->getParsedName()); + } } diff --git a/src/ai-bundle/src/Profiler/DataCollector.php b/src/ai-bundle/src/Profiler/DataCollector.php index e3041a2d6..a9587e093 100644 --- a/src/ai-bundle/src/Profiler/DataCollector.php +++ b/src/ai-bundle/src/Profiler/DataCollector.php @@ -11,6 +11,8 @@ namespace Symfony\AI\AiBundle\Profiler; +use Symfony\AI\Agent\Chat\MessageStoreInterface; +use Symfony\AI\Agent\ChatInterface; use Symfony\AI\Agent\Toolbox\ToolboxInterface; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\Tool\Tool; @@ -24,6 +26,7 @@ * * @phpstan-import-type PlatformCallData from TraceablePlatform * @phpstan-import-type ToolCallData from TraceableToolbox + * @phpstan-import-type MessageStoreData from TraceableMessageStore */ final class DataCollector extends AbstractDataCollector implements LateDataCollectorInterface { @@ -38,16 +41,32 @@ final class DataCollector extends AbstractDataCollector implements LateDataColle private readonly array $toolboxes; /** - * @param TraceablePlatform[] $platforms - * @param TraceableToolbox[] $toolboxes + * @var MessageStoreInterface[] + */ + private readonly array $messageStores; + + /** + * @var ChatInterface[] + */ + private readonly array $chats; + + /** + * @param TraceablePlatform[] $platforms + * @param TraceableToolbox[] $toolboxes + * @param TraceableMessageStore[] $messageStores + * @param TraceableChat[] $chats */ public function __construct( iterable $platforms, private readonly ToolboxInterface $defaultToolBox, iterable $toolboxes, + iterable $messageStores, + iterable $chats, ) { $this->platforms = $platforms instanceof \Traversable ? iterator_to_array($platforms) : $platforms; $this->toolboxes = $toolboxes instanceof \Traversable ? iterator_to_array($toolboxes) : $toolboxes; + $this->messageStores = $messageStores instanceof \Traversable ? iterator_to_array($messageStores) : $messageStores; + $this->chats = $chats instanceof \Traversable ? iterator_to_array($chats) : $chats; } public function collect(Request $request, Response $response, ?\Throwable $exception = null): void @@ -60,7 +79,9 @@ public function lateCollect(): void $this->data = [ 'tools' => $this->defaultToolBox->getTools(), 'platform_calls' => array_merge(...array_map($this->awaitCallResults(...), $this->platforms)), - 'tool_calls' => array_merge(...array_map(fn (TraceableToolbox $toolbox) => $toolbox->calls, $this->toolboxes)), + 'tool_calls' => array_merge(...array_map(static fn (TraceableToolbox $toolbox): array => $toolbox->calls, $this->toolboxes)), + 'message_stores' => array_merge(...array_map(static fn (TraceableMessageStore $messageStore): array => $messageStore->messages, $this->messageStores)), + 'chats_ids' => array_map(static fn (TraceableChat $chat): string => $chat->getId(), $this->chats), ]; } @@ -93,6 +114,22 @@ public function getToolCalls(): array return $this->data['tool_calls'] ?? []; } + /** + * @return MessageStoreData[] + */ + public function getMessageStores(): array + { + return $this->data['message_stores'] ?? []; + } + + /** + * @return string[] + */ + public function getChatsIds(): array + { + return $this->data['chats_ids'] ?? []; + } + /** * @return array{ * model: Model, diff --git a/src/ai-bundle/src/Profiler/TraceableChat.php b/src/ai-bundle/src/Profiler/TraceableChat.php new file mode 100644 index 000000000..35fafe32c --- /dev/null +++ b/src/ai-bundle/src/Profiler/TraceableChat.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\AiBundle\Profiler; + +use Symfony\AI\Chat\ChatInterface; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\UserMessage; + +/** + * @author Guillaume Loulier + */ +final readonly class TraceableChat implements ChatInterface +{ + public function __construct( + private ChatInterface $chat, + ) { + } + + public function initiate(MessageBag $messages, ?string $id = null): void + { + $this->chat->initiate($messages, $id); + } + + public function submit(UserMessage $message, ?string $id = null): AssistantMessage + { + return $this->chat->submit($message, $id); + } +} diff --git a/src/ai-bundle/src/Profiler/TraceableMessageStore.php b/src/ai-bundle/src/Profiler/TraceableMessageStore.php new file mode 100644 index 000000000..f7adb4990 --- /dev/null +++ b/src/ai-bundle/src/Profiler/TraceableMessageStore.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\AiBundle\Profiler; + +use Symfony\AI\Chat\MessageStoreInterface; +use Symfony\AI\Platform\Message\MessageBag; + +/** + * @author Guillaume Loulier + * + * @phpstan-type MessageStoreData array + */ +final class TraceableMessageStore implements MessageStoreInterface +{ + /** + * @var MessageStoreData[] + */ + public array $messages = []; + + public function __construct( + private readonly MessageStoreInterface $store, + ) { + } + + public function save(MessageBag $messages, ?string $id = null): void + { + $this->store->save($messages, $id); + + $this->messages[$id ?? $this->store->getId()][] = [ + 'message' => $messages, + 'time' => new \DateTimeImmutable(), + ]; + } + + public function load(?string $id = null): MessageBag + { + return $this->store->load($id); + } + + public function clear(?string $id = null): void + { + $this->store->clear($id); + } + + public function getId(): string + { + return $this->store->getId(); + } +} diff --git a/src/ai-bundle/templates/data_collector.html.twig b/src/ai-bundle/templates/data_collector.html.twig index 359189784..e98b1c3aa 100644 --- a/src/ai-bundle/templates/data_collector.html.twig +++ b/src/ai-bundle/templates/data_collector.html.twig @@ -79,6 +79,17 @@ Tool Calls +
+
+
+ {{- collector.chatsIds|length -}} + Chats +
+
+ {{- collector.messageStores|length -}} + Chats calls +
+

Platform Calls

{% if collector.platformCalls|length %} diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php index 60b452372..c0f8008fc 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -2117,6 +2117,45 @@ public function testIndexerWithSourceFiltersAndTransformers() $this->assertSame('logger', (string) $arguments[6]); } + public function testAgentsWithChatCanBeDefined() + { + $container = $this->buildContainer([ + 'ai' => [ + 'agent' => [ + 'another_agent' => [ + 'model' => [ + 'class' => 'Symfony\AI\Platform\Bridge\Anthropic\Claude', + 'name' => 'claude-3-opus-20240229', + ], + 'system_prompt' => 'Be concise.', + ], + ], + 'message_store' => [ + 'cache' => [ + 'main_cache' => [ + 'service' => 'cache.app', + ], + ], + ], + 'chat' => [ + 'main' => [ + 'agent' => 'another_agent', + 'message_store' => 'cache', + ], + 'second' => [ + 'agent' => 'another_agent', + 'message_store' => 'cache', + ], + ], + ], + ]); + + $this->assertTrue($container->hasAlias('Symfony\AI\Agent\ChatInterface')); + $this->assertTrue($container->hasAlias('Symfony\AI\Agent\Chat\MessageStoreInterface')); + $this->assertTrue($container->hasDefinition('ai.message_store.cache.main_cache')); + $this->assertTrue($container->hasDefinition('ai.chat.main')); + } + private function buildContainer(array $configuration): ContainerBuilder { $container = new ContainerBuilder(); @@ -2386,6 +2425,37 @@ private function getFullConfig(): array 'store' => 'my_azure_search_store_service_id', ], ], + 'message_store' => [ + 'cache' => [ + 'my_cache_message_store' => [ + 'service' => 'cache.system', + 'identifier' => 'foo', + ], + 'my_cache_message_store_with_ttl' => [ + 'service' => 'cache.system', + 'identifier' => 'foo', + 'ttl' => 3600, + ], + 'my_memory_message_store' => [], + 'my_session_message_store' => [ + 'identifier' => 'bar', + ], + ], + ], + 'chat' => [ + 'my_main_chat_with_cache_store' => [ + 'agent' => 'my_chat_agent', + 'message_store' => 'my_cache_message_store', + ], + 'my_second_chat_with_memory_store' => [ + 'agent' => 'my_chat_agent', + 'message_store' => 'my_memory_message_store', + ], + 'my_chat_with_session_store' => [ + 'agent' => 'my_chat_agent', + 'message_store' => 'my_session_message_store', + ], + ], ], ]; } diff --git a/src/ai-bundle/tests/Profiler/DataCollectorTest.php b/src/ai-bundle/tests/Profiler/DataCollectorTest.php index 5c5bd4a13..ba2071f44 100644 --- a/src/ai-bundle/tests/Profiler/DataCollectorTest.php +++ b/src/ai-bundle/tests/Profiler/DataCollectorTest.php @@ -44,7 +44,7 @@ public function testCollectsDataForNonStreamingResponse() $result = $traceablePlatform->invoke($this->createStub(Model::class), $messageBag, ['stream' => false]); $this->assertSame('Assistant response', $result->asText()); - $dataCollector = new DataCollector([$traceablePlatform], $this->createStub(ToolboxInterface::class), []); + $dataCollector = new DataCollector([$traceablePlatform], $this->createStub(ToolboxInterface::class), [], [], []); $dataCollector->lateCollect(); $this->assertCount(1, $dataCollector->getPlatformCalls()); @@ -68,7 +68,7 @@ public function testCollectsDataForStreamingResponse() $result = $traceablePlatform->invoke($this->createStub(Model::class), $messageBag, ['stream' => true]); $this->assertSame('Assistant response', implode('', iterator_to_array($result->asStream()))); - $dataCollector = new DataCollector([$traceablePlatform], $this->createStub(ToolboxInterface::class), []); + $dataCollector = new DataCollector([$traceablePlatform], $this->createStub(ToolboxInterface::class), [], [], []); $dataCollector->lateCollect(); $this->assertCount(1, $dataCollector->getPlatformCalls()); diff --git a/src/ai-bundle/tests/Profiler/TraceableChatTest.php b/src/ai-bundle/tests/Profiler/TraceableChatTest.php new file mode 100644 index 000000000..0182d1517 --- /dev/null +++ b/src/ai-bundle/tests/Profiler/TraceableChatTest.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\AiBundle\Tests\Profiler; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\AgentInterface; +use Symfony\AI\Agent\Chat; +use Symfony\AI\AiBundle\Profiler\TraceableChat; +use Symfony\AI\Chat\Bridge\Local\InMemoryStore; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Result\TextResult; + +#[CoversClass(TraceableChat::class)] +#[UsesClass(InMemoryStore::class)] +#[UsesClass(Chat::class)] +#[UsesClass(Message::class)] +#[UsesClass(TextResult::class)] +final class TraceableChatTest extends TestCase +{ + public function testCurrentMessageBagCanBeRetrieved() + { + $agent = $this->createMock(AgentInterface::class); + $agent->expects($this->once())->method('call')->willReturn(new TextResult('foo')); + + $store = new InMemoryStore('foo'); + $chat = new Chat($agent, $store); + + $traceableChat = new TraceableChat($chat); + $traceableChat->submit(Message::ofUser('foo')); + + $this->assertCount(2, $traceableChat->getCurrentMessageBag()); + } + + public function testSpecificMessageBagCanBeRetrieved() + { + $agent = $this->createMock(AgentInterface::class); + $agent->expects($this->once())->method('call')->willReturn(new TextResult('foo')); + + $store = new InMemoryStore('foo'); + $chat = new Chat($agent, $store); + + $traceableChat = new TraceableChat($chat); + $traceableChat->submit(Message::ofUser('foo')); + + $this->assertCount(2, $traceableChat->getCurrentMessageBag()); + $this->assertCount(0, $traceableChat->getMessageBag('bar')); + } +} diff --git a/src/ai-bundle/tests/Profiler/TraceableMessageStoreTest.php b/src/ai-bundle/tests/Profiler/TraceableMessageStoreTest.php new file mode 100644 index 000000000..8eaf82b1e --- /dev/null +++ b/src/ai-bundle/tests/Profiler/TraceableMessageStoreTest.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\AiBundle\Tests\Profiler; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\AiBundle\Profiler\TraceableMessageStore; +use Symfony\AI\Chat\Bridge\Local\InMemoryStore; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +#[CoversClass(TraceableMessageStore::class)] +#[UsesClass(TraceableMessageStore::class)] +#[UsesClass(InMemoryStore::class)] +#[UsesClass(MessageBag::class)] +#[UsesClass(Message::class)] +final class TraceableMessageStoreTest extends TestCase +{ + public function testStoreIsConfigured() + { + $messageStore = new InMemoryStore(); + + $traceableMessageStore = new TraceableMessageStore($messageStore); + + $this->assertSame('_message_store_memory', $traceableMessageStore->getId()); + } + + public function testMessagesCanBeSaved() + { + $messageStore = new InMemoryStore(); + + $traceableMessageStore = new TraceableMessageStore($messageStore); + $traceableMessageStore->save(new MessageBag( + Message::ofUser('foo'), + )); + + $this->assertArrayHasKey('_message_store_memory', $traceableMessageStore->messages); + $this->assertCount(1, $traceableMessageStore->messages['_message_store_memory']); + + $traceableMessageStore->save(new MessageBag( + Message::ofUser('bar'), + ), 'bar'); + + $this->assertArrayHasKey('bar', $traceableMessageStore->messages); + $this->assertCount(1, $traceableMessageStore->messages['bar']); + } + + public function testMessagesCanBeLoaded() + { + $messageStore = new InMemoryStore(); + + $traceableMessageStore = new TraceableMessageStore($messageStore); + $traceableMessageStore->save(new MessageBag( + Message::ofUser('foo'), + )); + + $this->assertCount(1, $traceableMessageStore->load()); + } + + public function testMessagesCanBeCleared() + { + $messageStore = new InMemoryStore(); + + $traceableMessageStore = new TraceableMessageStore($messageStore); + $traceableMessageStore->save(new MessageBag( + Message::ofUser('foo'), + )); + + $this->assertCount(1, $traceableMessageStore->load()); + + $traceableMessageStore->clear(); + + $this->assertCount(0, $traceableMessageStore->load()); + } +} diff --git a/src/chat/.gitattributes b/src/chat/.gitattributes new file mode 100644 index 000000000..9cf0aaea6 --- /dev/null +++ b/src/chat/.gitattributes @@ -0,0 +1,8 @@ +/.github export-ignore +/tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +phpstan.dist.neon export-ignore +phpunit.xml.dist export-ignore +CLAUDE.md export-ignore +AGENTS.md export-ignore diff --git a/src/chat/LICENSE b/src/chat/LICENSE new file mode 100644 index 000000000..bc38d714e --- /dev/null +++ b/src/chat/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/chat/README.md b/src/chat/README.md new file mode 100644 index 000000000..3304389e2 --- /dev/null +++ b/src/chat/README.md @@ -0,0 +1,24 @@ +# Symfony AI - Chat Component + +The Chat component provides a low-level abstraction for triggering chat with an agent. + +**This Component is experimental**. +[Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) +are not covered by Symfony's +[Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). + +## Installation + +```bash +composer require symfony/ai-chat +``` + +**This repository is a READ-ONLY sub-tree split**. See +https://github.com/symfony/ai to create issues or submit pull requests. + +## Resources + +- [Documentation](doc/index.rst) +- [Report issues](https://github.com/symfony/ai/issues) and + [send Pull Requests](https://github.com/symfony/ai/pulls) + in the [main Symfony AI repository](https://github.com/symfony/ai) diff --git a/src/chat/composer.json b/src/chat/composer.json new file mode 100644 index 000000000..0f6e7c6b3 --- /dev/null +++ b/src/chat/composer.json @@ -0,0 +1,60 @@ +{ + "name": "symfony/ai-chat", + "description": "Low-level abstraction for triggering chat with an agent.", + "license": "MIT", + "type": "library", + "keywords": [ + "chat", + "agent", + "store", + "message" + ], + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" + } + ], + "require": { + "php": ">=8.2", + "psr/log": "^3.0", + "symfony/ai-agent": "@dev", + "symfony/ai-platform": "@dev", + "symfony/http-client": "^6.4 || ^7.1" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.5", + "psr/cache": "^3.0", + "symfony/cache": "^6.4 || ^7.1", + "symfony/http-foundation": "^6.4 || ^7.1" + }, + "suggest": { + "psr/cache": "To use any PSR-16 cache as a message store", + "symfony/http-foundation": "To use the session as a message store" + }, + "autoload": { + "psr-4": { + "Symfony\\AI\\Chat\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\AI\\PHPStan\\": "../../.phpstan/", + "Symfony\\AI\\Chat\\Tests\\": "tests/" + } + }, + "config": { + "sort-packages": true + }, + "extra": { + "thanks": { + "name": "symfony/ai", + "url": "https://github.com/symfony/ai" + } + } +} diff --git a/src/chat/doc/index.rst b/src/chat/doc/index.rst new file mode 100644 index 000000000..54189b575 --- /dev/null +++ b/src/chat/doc/index.rst @@ -0,0 +1,16 @@ +Symfony AI - Chat Component +============================ + +The Chat component provides a low-level abstraction for triggering a chat with an agent. + +Installation +------------ + +Install the component using Composer: + +.. code-block:: terminal + + composer require symfony/ai-chat + +Purpose +------- diff --git a/src/chat/phpstan.dist.neon b/src/chat/phpstan.dist.neon new file mode 100644 index 000000000..988f6c4d0 --- /dev/null +++ b/src/chat/phpstan.dist.neon @@ -0,0 +1,12 @@ +includes: + - ../../.phpstan/extension.neon + +parameters: + level: 6 + paths: + - src/ + - tests/ + treatPhpDocTypesAsCertain: false + ignoreErrors: + - + message: "#^Method .*::test.*\\(\\) has no return type specified\\.$#" diff --git a/src/chat/phpunit.xml.dist b/src/chat/phpunit.xml.dist new file mode 100644 index 000000000..7c04fa4fb --- /dev/null +++ b/src/chat/phpunit.xml.dist @@ -0,0 +1,24 @@ + + + + + tests + + + + + + src + + + diff --git a/src/agent/src/Chat/MessageStore/CacheStore.php b/src/chat/src/Bridge/Local/CacheStore.php similarity index 61% rename from src/agent/src/Chat/MessageStore/CacheStore.php rename to src/chat/src/Bridge/Local/CacheStore.php index e3875baba..4413cab4f 100644 --- a/src/agent/src/Chat/MessageStore/CacheStore.php +++ b/src/chat/src/Bridge/Local/CacheStore.php @@ -9,31 +9,36 @@ * file that was distributed with this source code. */ -namespace Symfony\AI\Agent\Chat\MessageStore; +namespace Symfony\AI\Chat\Bridge\Local; use Psr\Cache\CacheItemPoolInterface; -use Symfony\AI\Agent\Chat\MessageStoreInterface; use Symfony\AI\Agent\Exception\RuntimeException; +use Symfony\AI\Chat\MessageStoreIdentifierTrait; +use Symfony\AI\Chat\MessageStoreInterface; use Symfony\AI\Platform\Message\MessageBag; /** * @author Christopher Hertel */ -final readonly class CacheStore implements MessageStoreInterface +final class CacheStore implements MessageStoreInterface { + use MessageStoreIdentifierTrait; + public function __construct( private CacheItemPoolInterface $cache, - private string $cacheKey, + string $id = '_message_store_cache', private int $ttl = 86400, ) { if (!interface_exists(CacheItemPoolInterface::class)) { throw new RuntimeException('For using the CacheStore as message store, a PSR-6 cache implementation is required. Try running "composer require symfony/cache" or another PSR-6 compatible cache.'); } + + $this->setId($id); } - public function save(MessageBag $messages): void + public function save(MessageBag $messages, ?string $id = null): void { - $item = $this->cache->getItem($this->cacheKey); + $item = $this->cache->getItem($id ?? $this->getId()); $item->set($messages); $item->expiresAfter($this->ttl); @@ -41,15 +46,15 @@ public function save(MessageBag $messages): void $this->cache->save($item); } - public function load(): MessageBag + public function load(?string $id = null): MessageBag { - $item = $this->cache->getItem($this->cacheKey); + $item = $this->cache->getItem($id ?? $this->getId()); return $item->isHit() ? $item->get() : new MessageBag(); } - public function clear(): void + public function clear(?string $id = null): void { - $this->cache->deleteItem($this->cacheKey); + $this->cache->deleteItem($id ?? $this->getId()); } } diff --git a/src/chat/src/Bridge/Local/InMemoryStore.php b/src/chat/src/Bridge/Local/InMemoryStore.php new file mode 100644 index 000000000..f82c1a13e --- /dev/null +++ b/src/chat/src/Bridge/Local/InMemoryStore.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat\Bridge\Local; + +use Symfony\AI\Chat\MessageStoreIdentifierTrait; +use Symfony\AI\Chat\MessageStoreInterface; +use Symfony\AI\Platform\Message\MessageBag; + +/** + * @author Christopher Hertel + */ +final class InMemoryStore implements MessageStoreInterface +{ + use MessageStoreIdentifierTrait; + + /** + * @var MessageBag[] + */ + private array $messageBags; + + public function __construct( + string $id = '_message_store_memory', + ) { + $this->setId($id); + } + + public function save(MessageBag $messages, ?string $id = null): void + { + $this->messageBags[$id ?? $this->getId()] = $messages; + } + + public function load(?string $id = null): MessageBag + { + return $this->messageBags[$id ?? $this->getId()] ?? new MessageBag(); + } + + public function clear(?string $id = null): void + { + $this->messageBags[$id ?? $this->getId()] = new MessageBag(); + } +} diff --git a/src/agent/src/Chat/MessageStore/SessionStore.php b/src/chat/src/Bridge/Symfony/SessionStore.php similarity index 56% rename from src/agent/src/Chat/MessageStore/SessionStore.php rename to src/chat/src/Bridge/Symfony/SessionStore.php index b5a0cea63..411268467 100644 --- a/src/agent/src/Chat/MessageStore/SessionStore.php +++ b/src/chat/src/Bridge/Symfony/SessionStore.php @@ -9,10 +9,11 @@ * file that was distributed with this source code. */ -namespace Symfony\AI\Agent\Chat\MessageStore; +namespace Symfony\AI\Chat\Bridge\Symfony; -use Symfony\AI\Agent\Chat\MessageStoreInterface; -use Symfony\AI\Agent\Exception\RuntimeException; +use Symfony\AI\Chat\Exception\RuntimeException; +use Symfony\AI\Chat\MessageStoreIdentifierTrait; +use Symfony\AI\Chat\MessageStoreInterface; use Symfony\AI\Platform\Message\MessageBag; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\SessionInterface; @@ -20,32 +21,37 @@ /** * @author Christopher Hertel */ -final readonly class SessionStore implements MessageStoreInterface +final class SessionStore implements MessageStoreInterface { + use MessageStoreIdentifierTrait; + private SessionInterface $session; public function __construct( RequestStack $requestStack, - private string $sessionKey = 'messages', + string $id = '_message_store_session', ) { if (!class_exists(RequestStack::class)) { throw new RuntimeException('For using the SessionStore as message store, the symfony/http-foundation package is required. Try running "composer require symfony/http-foundation".'); } + $this->session = $requestStack->getSession(); + + $this->setId($id); } - public function save(MessageBag $messages): void + public function save(MessageBag $messages, ?string $id = null): void { - $this->session->set($this->sessionKey, $messages); + $this->session->set($id ?? $this->getId(), $messages); } - public function load(): MessageBag + public function load(?string $id = null): MessageBag { - return $this->session->get($this->sessionKey, new MessageBag()); + return $this->session->get($id ?? $this->getId(), new MessageBag()); } - public function clear(): void + public function clear(?string $id = null): void { - $this->session->remove($this->sessionKey); + $this->session->remove($id ?? $this->getId()); } } diff --git a/src/agent/src/Chat.php b/src/chat/src/Chat.php similarity index 66% rename from src/agent/src/Chat.php rename to src/chat/src/Chat.php index 1c594c8b2..a9adcca8a 100644 --- a/src/agent/src/Chat.php +++ b/src/chat/src/Chat.php @@ -9,9 +9,10 @@ * file that was distributed with this source code. */ -namespace Symfony\AI\Agent; +namespace Symfony\AI\Chat; -use Symfony\AI\Agent\Chat\MessageStoreInterface; +use Symfony\AI\Agent\AgentInterface; +use Symfony\AI\Chat\Exception\LogicException; use Symfony\AI\Platform\Message\AssistantMessage; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; @@ -51,4 +52,20 @@ public function submit(UserMessage $message): AssistantMessage return $assistantMessage; } + + public function fork(string $id): ChatInterface + { + $forkedMessagesBag = $this->store->load($id); + + if (!\array_key_exists(MessageStoreIdentifierTrait::class, class_uses($this->store))) { + throw new LogicException(\sprintf('The current store must implement "%s" to being able to be forked.', MessageStoreIdentifierTrait::class)); + } + + $this->store->setId($id); + + $self = new self($this->agent, $this->store); + $self->initiate($forkedMessagesBag); + + return $self; + } } diff --git a/src/agent/src/ChatInterface.php b/src/chat/src/ChatInterface.php similarity index 91% rename from src/agent/src/ChatInterface.php rename to src/chat/src/ChatInterface.php index ba17f92cc..5b98a3715 100644 --- a/src/agent/src/ChatInterface.php +++ b/src/chat/src/ChatInterface.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\AI\Agent; +namespace Symfony\AI\Chat; use Symfony\AI\Agent\Exception\ExceptionInterface; use Symfony\AI\Platform\Message\AssistantMessage; @@ -27,4 +27,6 @@ public function initiate(MessageBag $messages): void; * @throws ExceptionInterface When the chat submission fails due to agent errors */ public function submit(UserMessage $message): AssistantMessage; + + public function fork(string $id): self; } diff --git a/src/chat/src/Exception/ExceptionInterface.php b/src/chat/src/Exception/ExceptionInterface.php new file mode 100644 index 000000000..e0763505d --- /dev/null +++ b/src/chat/src/Exception/ExceptionInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat\Exception; + +/** + * @author Oskar Stark + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/chat/src/Exception/InvalidArgumentException.php b/src/chat/src/Exception/InvalidArgumentException.php new file mode 100644 index 000000000..97dcebab6 --- /dev/null +++ b/src/chat/src/Exception/InvalidArgumentException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat\Exception; + +/** + * @author Oskar Stark + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/chat/src/Exception/LogicException.php b/src/chat/src/Exception/LogicException.php new file mode 100644 index 000000000..85684959c --- /dev/null +++ b/src/chat/src/Exception/LogicException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat\Exception; + +/** + * @author Oskar Stark + */ +class LogicException extends \LogicException implements ExceptionInterface +{ +} diff --git a/src/chat/src/Exception/RuntimeException.php b/src/chat/src/Exception/RuntimeException.php new file mode 100644 index 000000000..a6390e279 --- /dev/null +++ b/src/chat/src/Exception/RuntimeException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat\Exception; + +/** + * @author Oskar Stark + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/chat/src/MessageStoreIdentifierTrait.php b/src/chat/src/MessageStoreIdentifierTrait.php new file mode 100644 index 000000000..b980b364a --- /dev/null +++ b/src/chat/src/MessageStoreIdentifierTrait.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat; + +trait MessageStoreIdentifierTrait +{ + private string $id; + + public function setId(string $id): void + { + $this->id = $id; + } + + public function getId(): string + { + return $this->id; + } +} diff --git a/src/agent/src/Chat/MessageStoreInterface.php b/src/chat/src/MessageStoreInterface.php similarity index 50% rename from src/agent/src/Chat/MessageStoreInterface.php rename to src/chat/src/MessageStoreInterface.php index 06651ad97..f8df8f3bd 100644 --- a/src/agent/src/Chat/MessageStoreInterface.php +++ b/src/chat/src/MessageStoreInterface.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\AI\Agent\Chat; +namespace Symfony\AI\Chat; use Symfony\AI\Platform\Message\MessageBag; @@ -18,9 +18,12 @@ */ interface MessageStoreInterface { - public function save(MessageBag $messages): void; + /** + * @param string|null $id If null, the current message bag will be stored under an auto-generated UUID accessible via {@see ChatInterface::getId()} + */ + public function save(MessageBag $messages, ?string $id = null): void; - public function load(): MessageBag; + public function load(?string $id = null): MessageBag; - public function clear(): void; + public function clear(?string $id = null): void; } diff --git a/src/chat/tests/Bridge/Local/CacheStoreTest.php b/src/chat/tests/Bridge/Local/CacheStoreTest.php new file mode 100644 index 000000000..803ccb539 --- /dev/null +++ b/src/chat/tests/Bridge/Local/CacheStoreTest.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat\Tests\Bridge\Local; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Chat\Bridge\Local\CacheStore; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\Component\Cache\Adapter\ArrayAdapter; + +#[CoversClass(CacheStore::class)] +#[UsesClass(MessageBag::class)] +#[UsesClass(Message::class)] +final class CacheStoreTest extends TestCase +{ + public function testItCanStore() + { + $messageBag = new MessageBag(); + $messageBag->add(Message::ofUser('Hello')); + + $store = new CacheStore(new ArrayAdapter()); + $store->save($messageBag); + + $this->assertCount(1, $store->load('_message_store_cache')); + } + + public function testItCanStoreMultipleMessageBags() + { + $firstMessageBag = new MessageBag(); + $firstMessageBag->add(Message::ofUser('Hello')); + + $secondMessageBag = new MessageBag(); + $secondMessageBag->add(Message::ofUser('Hello')); + $secondMessageBag->add(Message::ofUser('Hello')); + + $store = new CacheStore(new ArrayAdapter()); + $store->save($firstMessageBag, 'foo'); + $store->save($secondMessageBag, 'bar'); + + $this->assertCount(1, $store->load('foo')); + $this->assertCount(2, $store->load('bar')); + $this->assertCount(0, $store->load('_message_store_cache')); + } + + public function testItCanClear() + { + $bag = new MessageBag(); + $bag->add(Message::ofUser('Hello')); + $bag->add(Message::ofUser('Hello')); + + $store = new CacheStore(new ArrayAdapter()); + $store->save($bag); + + $this->assertCount(2, $store->load('_message_store_cache')); + + $store->clear(); + + $this->assertCount(0, $store->load('_message_store_cache')); + } +} diff --git a/src/chat/tests/Bridge/Local/InMemoryStoreTest.php b/src/chat/tests/Bridge/Local/InMemoryStoreTest.php new file mode 100644 index 000000000..d023b6417 --- /dev/null +++ b/src/chat/tests/Bridge/Local/InMemoryStoreTest.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat\Tests\Bridge\Local; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Chat\Bridge\Local\InMemoryStore; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +#[CoversClass(InMemoryStore::class)] +#[UsesClass(MessageBag::class)] +#[UsesClass(Message::class)] +final class InMemoryStoreTest extends TestCase +{ + public function testItCanStore() + { + $messageBag = new MessageBag(); + $messageBag->add(Message::ofUser('Hello')); + + $store = new InMemoryStore(); + $store->save($messageBag); + + $this->assertCount(1, $store->load('_message_store_memory')); + } + + public function testItCanStoreMultipleMessageBags() + { + $firstMessageBag = new MessageBag(); + $firstMessageBag->add(Message::ofUser('Hello')); + + $secondMessageBag = new MessageBag(); + $secondMessageBag->add(Message::ofUser('Hello')); + $secondMessageBag->add(Message::ofUser('Hello')); + + $store = new InMemoryStore(); + $store->save($firstMessageBag, 'foo'); + $store->save($secondMessageBag, 'bar'); + + $this->assertCount(1, $store->load('foo')); + $this->assertCount(2, $store->load('bar')); + $this->assertCount(0, $store->load('_message_store_memory')); + } + + public function testItCanClear() + { + $bag = new MessageBag(); + $bag->add(Message::ofUser('Hello')); + $bag->add(Message::ofUser('Hello')); + + $store = new InMemoryStore(); + $store->save($bag); + + $this->assertCount(2, $store->load('_message_store_memory')); + + $store->clear(); + + $this->assertCount(0, $store->load('_message_store_memory')); + } +} diff --git a/src/chat/tests/Bridge/Symfony/SessionStoreTest.php b/src/chat/tests/Bridge/Symfony/SessionStoreTest.php new file mode 100644 index 000000000..c74bc30cc --- /dev/null +++ b/src/chat/tests/Bridge/Symfony/SessionStoreTest.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat\Tests\Bridge\Symfony; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Chat\Bridge\Symfony\SessionStore; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; + +#[CoversClass(SessionStore::class)] +#[UsesClass(MessageBag::class)] +#[UsesClass(Message::class)] +final class SessionStoreTest extends TestCase +{ + public function testItCanStore() + { + $storage = new MockArraySessionStorage(); + $storage->start(); + + $request = Request::create('/'); + $request->setSession(new Session($storage)); + + $requestStack = new RequestStack(); + $requestStack->push($request); + + $messageBag = new MessageBag(); + $messageBag->add(Message::ofUser('Hello')); + + $store = new SessionStore($requestStack); + $store->save($messageBag); + + $this->assertCount(1, $store->load()); + } + + public function testItCanStoreMultipleMessageBags() + { + $storage = new MockArraySessionStorage(); + $storage->start(); + + $request = Request::create('/'); + $request->setSession(new Session($storage)); + + $requestStack = new RequestStack(); + $requestStack->push($request); + + $firstMessageBag = new MessageBag(); + $firstMessageBag->add(Message::ofUser('Hello')); + + $secondMessageBag = new MessageBag(); + $secondMessageBag->add(Message::ofUser('Hello')); + + $store = new SessionStore($requestStack); + $store->save($firstMessageBag, 'foo'); + $store->save($secondMessageBag, 'bar'); + + $this->assertCount(1, $store->load('foo')); + $this->assertCount(1, $store->load('bar')); + $this->assertCount(0, $store->load()); + } + + public function testItCanClear() + { + $storage = new MockArraySessionStorage(); + $storage->start(); + + $request = Request::create('/'); + $request->setSession(new Session($storage)); + + $requestStack = new RequestStack(); + $requestStack->push($request); + + $bag = new MessageBag(); + $bag->add(Message::ofUser('Hello')); + $bag->add(Message::ofUser('Hello')); + + $store = new SessionStore($requestStack); + $store->save($bag); + + $this->assertCount(2, $store->load()); + + $store->clear(); + + $this->assertCount(0, $store->load()); + } +} diff --git a/src/agent/tests/ChatTest.php b/src/chat/tests/ChatTest.php similarity index 67% rename from src/agent/tests/ChatTest.php rename to src/chat/tests/ChatTest.php index 3cd403eb8..be6f86dc9 100644 --- a/src/agent/tests/ChatTest.php +++ b/src/chat/tests/ChatTest.php @@ -9,25 +9,34 @@ * file that was distributed with this source code. */ -namespace Symfony\AI\Agent\Tests; +namespace Symfony\AI\Chat\Tests; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Agent; use Symfony\AI\Agent\AgentInterface; -use Symfony\AI\Agent\Chat; -use Symfony\AI\Agent\Chat\MessageStoreInterface; +use Symfony\AI\Chat\Bridge\Local\InMemoryStore; +use Symfony\AI\Chat\Chat; +use Symfony\AI\Chat\MessageStoreInterface; use Symfony\AI\Platform\Message\AssistantMessage; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\AI\Platform\Result\RawResultInterface; +use Symfony\AI\Platform\Result\ResultPromise; use Symfony\AI\Platform\Result\TextResult; #[CoversClass(Chat::class)] +#[UsesClass(Agent::class)] #[UsesClass(Message::class)] #[UsesClass(MessageBag::class)] #[UsesClass(TextResult::class)] +#[UsesClass(InMemoryStore::class)] +#[UsesClass(ResultPromise::class)] #[Small] final class ChatTest extends TestCase { @@ -165,4 +174,65 @@ public function testItHandlesEmptyMessageStore() $this->assertInstanceOf(AssistantMessage::class, $result); $this->assertSame($assistantContent, $result->content); } + + public function testChatCanUseAnotherAgentOnceInitialized() + { + $rawResult = $this->createStub(RawResultInterface::class); + + $platform = $this->createMock(PlatformInterface::class); + $platform->method('invoke') + ->willReturn(new ResultPromise(static fn (): TextResult => new TextResult('Assistant response'), $rawResult)); + + $model = $this->createMock(Model::class); + + $firstAgent = new Agent($platform, $model); + + $store = new InMemoryStore(); + + $chat = new Chat($firstAgent, $store); + $chat->submit(Message::ofUser('First message')); + + $this->assertCount(2, $store->load()); + + $secondAgent = new Agent($platform, $model); + $chat = new Chat($secondAgent, $store); + $chat->submit(Message::ofUser('Second message')); + + $this->assertCount(4, $store->load()); + } + + public function testChatCanBeForked() + { + $rawResult = $this->createStub(RawResultInterface::class); + + $platform = $this->createMock(PlatformInterface::class); + $platform->method('invoke') + ->willReturn(new ResultPromise(static fn (): TextResult => new TextResult('Assistant response'), $rawResult)); + + $model = $this->createMock(Model::class); + + $firstAgent = new Agent($platform, $model); + + $store = new InMemoryStore(); + + $chat = new Chat($firstAgent, $store); + $chat->submit(Message::ofUser('First message')); + + $this->assertCount(2, $store->load()); + + $forkedChat = $chat->fork('foo'); + $forkedChat->submit(Message::ofUser('First message')); + $forkedChat->submit(Message::ofUser('Second message')); + + $this->assertCount(4, $store->load('foo')); + $this->assertCount(2, $store->load('_message_store_memory')); + + $forkedBackChat = $forkedChat->fork('_message_store_memory'); + $forkedBackChat->submit(Message::ofUser('First message')); + $forkedBackChat->submit(Message::ofUser('Second message')); + $forkedBackChat->submit(Message::ofUser('Second message')); + + $this->assertCount(4, $store->load('foo')); + $this->assertCount(8, $store->load('_message_store_memory')); + } }