From 8dfb6173c4fb6b985ff7d5fbbe78b6907ffbd403 Mon Sep 17 00:00:00 2001 From: Guillaume Loulier Date: Sun, 3 Aug 2025 19:09:23 +0200 Subject: [PATCH 1/2] feat(agent): chat session --- demo/config/packages/ai.yaml | 16 +++ demo/src/Audio/Chat.php | 75 ----------- demo/src/Audio/TwigComponent.php | 30 ++++- demo/src/Wikipedia/Chat.php | 60 --------- demo/src/Wikipedia/TwigComponent.php | 15 ++- demo/src/YouTube/Chat.php | 82 ------------ demo/src/YouTube/TwigComponent.php | 42 +++++- .../misc/persistent-chat-double-agent.php | 52 ++++++++ src/agent/src/Chat.php | 36 +++-- .../src/Chat/MessageStore/CacheStore.php | 19 ++- .../src/Chat/MessageStore/InMemoryStore.php | 27 +++- .../src/Chat/MessageStore/SessionStore.php | 20 ++- src/agent/src/Chat/MessageStoreInterface.php | 12 +- src/agent/src/ChatInterface.php | 10 +- src/agent/tests/AgentTest.php | 37 ++++++ src/agent/tests/Chat/CacheStoreTest.php | 71 ++++++++++ src/agent/tests/Chat/InMemoryStoreTest.php | 70 ++++++++++ src/agent/tests/Chat/SessionStoreTest.php | 100 ++++++++++++++ src/agent/tests/ChatTest.php | 16 +++ src/ai-bundle/config/options.php | 49 +++++++ src/ai-bundle/config/services.php | 2 + src/ai-bundle/doc/index.rst | 8 ++ src/ai-bundle/src/AiBundle.php | 124 ++++++++++++++++++ src/ai-bundle/src/Profiler/DataCollector.php | 43 +++++- src/ai-bundle/src/Profiler/TraceableChat.php | 53 ++++++++ .../src/Profiler/TraceableMessageStore.php | 61 +++++++++ .../templates/data_collector.html.twig | 11 ++ .../DependencyInjection/AiBundleTest.php | 66 ++++++++++ .../tests/Profiler/DataCollectorTest.php | 4 +- .../tests/Profiler/TraceableChatTest.php | 71 ++++++++++ .../Profiler/TraceableMessageStoreTest.php | 85 ++++++++++++ 31 files changed, 1097 insertions(+), 270 deletions(-) delete mode 100644 demo/src/Audio/Chat.php delete mode 100644 demo/src/Wikipedia/Chat.php delete mode 100644 demo/src/YouTube/Chat.php create mode 100644 examples/misc/persistent-chat-double-agent.php create mode 100644 src/agent/tests/Chat/CacheStoreTest.php create mode 100644 src/agent/tests/Chat/InMemoryStoreTest.php create mode 100644 src/agent/tests/Chat/SessionStoreTest.php create mode 100644 src/ai-bundle/src/Profiler/TraceableChat.php create mode 100644 src/ai-bundle/src/Profiler/TraceableMessageStore.php create mode 100644 src/ai-bundle/tests/Profiler/TraceableChatTest.php create mode 100644 src/ai-bundle/tests/Profiler/TraceableMessageStoreTest.php 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/misc/persistent-chat-double-agent.php b/examples/misc/persistent-chat-double-agent.php new file mode 100644 index 000000000..02c43aa2b --- /dev/null +++ b/examples/misc/persistent-chat-double-agent.php @@ -0,0 +1,52 @@ + + * + * 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\Agent\Chat; +use Symfony\AI\Agent\Chat\MessageStore\InMemoryStore; +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(); + +$firstChat = new Chat($agent, $store); +$secondChat = new Chat($agent, $store); + +$firstChat->initiate(new MessageBag( + Message::forSystem('You are a helpful assistant. You only answer with short sentences.'), +), '_first_chat'); +$secondChat->initiate(new MessageBag( + Message::forSystem('You are a helpful assistant. You only answer with short sentences.'), +), '_second_chat'); + +$firstChat->submit(Message::ofUser('My name is Christopher.')); +$firstChatMessage = $firstChat->submit(Message::ofUser('What is my name?')); + +$secondChat->submit(Message::ofUser('My name is William.')); +$secondChatMessage = $secondChat->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/src/agent/src/Chat.php b/src/agent/src/Chat.php index 1c594c8b2..0ab2724e2 100644 --- a/src/agent/src/Chat.php +++ b/src/agent/src/Chat.php @@ -21,23 +21,28 @@ /** * @author Christopher Hertel */ -final readonly class Chat implements ChatInterface +final class Chat implements ChatInterface { + private string $id; + public function __construct( - private AgentInterface $agent, - private MessageStoreInterface $store, + private readonly AgentInterface $agent, + private readonly MessageStoreInterface $store, ) { + $this->id = $this->store->getId(); } - public function initiate(MessageBag $messages): void + public function initiate(MessageBag $messages, ?string $id = null): void { + $this->id = $id ?? $this->id; + $this->store->clear(); - $this->store->save($messages); + $this->store->save($messages, $this->id); } - public function submit(UserMessage $message): AssistantMessage + public function submit(UserMessage $message, ?string $id = null): AssistantMessage { - $messages = $this->store->load(); + $messages = $this->store->load($id ?? $this->id); $messages->add($message); $result = $this->agent->call($messages); @@ -47,8 +52,23 @@ public function submit(UserMessage $message): AssistantMessage $assistantMessage = Message::ofAssistant($result->getContent()); $messages->add($assistantMessage); - $this->store->save($messages); + $this->store->save($messages, $this->id); return $assistantMessage; } + + public function getCurrentMessageBag(): MessageBag + { + return $this->store->load($this->id); + } + + public function getMessageBag(string $id): MessageBag + { + return $this->store->load($id); + } + + public function getId(): string + { + return $this->id; + } } diff --git a/src/agent/src/Chat/MessageStore/CacheStore.php b/src/agent/src/Chat/MessageStore/CacheStore.php index e3875baba..a228dbbcf 100644 --- a/src/agent/src/Chat/MessageStore/CacheStore.php +++ b/src/agent/src/Chat/MessageStore/CacheStore.php @@ -23,7 +23,7 @@ { public function __construct( private CacheItemPoolInterface $cache, - private string $cacheKey, + private string $id = '_message_store_cache', private int $ttl = 86400, ) { if (!interface_exists(CacheItemPoolInterface::class)) { @@ -31,9 +31,9 @@ public function __construct( } } - 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->id); $item->set($messages); $item->expiresAfter($this->ttl); @@ -41,15 +41,20 @@ 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->id); 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->id); + } + + public function getId(): string + { + return $this->id; } } diff --git a/src/agent/src/Chat/MessageStore/InMemoryStore.php b/src/agent/src/Chat/MessageStore/InMemoryStore.php index 41e01ed1e..7f9d90975 100644 --- a/src/agent/src/Chat/MessageStore/InMemoryStore.php +++ b/src/agent/src/Chat/MessageStore/InMemoryStore.php @@ -19,20 +19,33 @@ */ final class InMemoryStore implements MessageStoreInterface { - private MessageBag $messages; + /** + * @var MessageBag[] + */ + private array $messageBags; - public function save(MessageBag $messages): void + public function __construct( + private readonly string $id = '_message_store_memory', + ) { + } + + public function save(MessageBag $messages, ?string $id = null): void + { + $this->messageBags[$id ?? $this->id] = $messages; + } + + public function load(?string $id = null): MessageBag { - $this->messages = $messages; + return $this->messageBags[$id ?? $this->id] ?? new MessageBag(); } - public function load(): MessageBag + public function clear(?string $id = null): void { - return $this->messages ?? new MessageBag(); + $this->messageBags[$id ?? $this->id] = new MessageBag(); } - public function clear(): void + public function getId(): string { - $this->messages = new MessageBag(); + return $this->id; } } diff --git a/src/agent/src/Chat/MessageStore/SessionStore.php b/src/agent/src/Chat/MessageStore/SessionStore.php index b5a0cea63..1bfc07c8b 100644 --- a/src/agent/src/Chat/MessageStore/SessionStore.php +++ b/src/agent/src/Chat/MessageStore/SessionStore.php @@ -26,26 +26,32 @@ public function __construct( RequestStack $requestStack, - private string $sessionKey = 'messages', + private 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(); } - public function save(MessageBag $messages): void + public function save(MessageBag $messages, ?string $id = null): void + { + $this->session->set($id ?? $this->id, $messages); + } + + public function load(?string $id = null): MessageBag { - $this->session->set($this->sessionKey, $messages); + return $this->session->get($id ?? $this->id, new MessageBag()); } - public function load(): MessageBag + public function clear(?string $id = null): void { - return $this->session->get($this->sessionKey, new MessageBag()); + $this->session->remove($id ?? $this->id); } - public function clear(): void + public function getId(): string { - $this->session->remove($this->sessionKey); + return $this->id; } } diff --git a/src/agent/src/Chat/MessageStoreInterface.php b/src/agent/src/Chat/MessageStoreInterface.php index 06651ad97..38abd6b9b 100644 --- a/src/agent/src/Chat/MessageStoreInterface.php +++ b/src/agent/src/Chat/MessageStoreInterface.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Agent\Chat; +use Symfony\AI\Agent\ChatInterface; use Symfony\AI\Platform\Message\MessageBag; /** @@ -18,9 +19,14 @@ */ 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; + + public function getId(): string; } diff --git a/src/agent/src/ChatInterface.php b/src/agent/src/ChatInterface.php index ba17f92cc..54f8a473c 100644 --- a/src/agent/src/ChatInterface.php +++ b/src/agent/src/ChatInterface.php @@ -21,10 +21,16 @@ */ interface ChatInterface { - public function initiate(MessageBag $messages): void; + public function initiate(MessageBag $messages, ?string $id = null): void; /** * @throws ExceptionInterface When the chat submission fails due to agent errors */ - public function submit(UserMessage $message): AssistantMessage; + public function submit(UserMessage $message, ?string $id = null): AssistantMessage; + + public function getCurrentMessageBag(): MessageBag; + + public function getMessageBag(string $id): MessageBag; + + public function getId(): string; } diff --git a/src/agent/tests/AgentTest.php b/src/agent/tests/AgentTest.php index 06d4b8e34..7e5698911 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 testDoubleAgentCanUseSameMessageStore() + { + $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.'), + ), 'foo'); + $secondChat->initiate(new MessageBag( + Message::forSystem('You are a helpful assistant. You only answer with short sentences.'), + ), 'bar'); + + $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/agent/tests/Chat/CacheStoreTest.php b/src/agent/tests/Chat/CacheStoreTest.php new file mode 100644 index 000000000..2fe2fa57d --- /dev/null +++ b/src/agent/tests/Chat/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\Agent\Tests\Chat; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Chat\MessageStore\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/agent/tests/Chat/InMemoryStoreTest.php b/src/agent/tests/Chat/InMemoryStoreTest.php new file mode 100644 index 000000000..98a8df664 --- /dev/null +++ b/src/agent/tests/Chat/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\Agent\Tests\Chat; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Chat\MessageStore\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/agent/tests/Chat/SessionStoreTest.php b/src/agent/tests/Chat/SessionStoreTest.php new file mode 100644 index 000000000..0ff41293a --- /dev/null +++ b/src/agent/tests/Chat/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\Agent\Tests\Chat; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Chat\MessageStore\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('_message_store_session')); + } + + 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('_message_store_session')); + } + + 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('_message_store_session')); + + $store->clear(); + + $this->assertCount(0, $store->load('_message_store_session')); + } +} diff --git a/src/agent/tests/ChatTest.php b/src/agent/tests/ChatTest.php index 3cd403eb8..f36770831 100644 --- a/src/agent/tests/ChatTest.php +++ b/src/agent/tests/ChatTest.php @@ -18,6 +18,7 @@ use PHPUnit\Framework\TestCase; use Symfony\AI\Agent\AgentInterface; use Symfony\AI\Agent\Chat; +use Symfony\AI\Agent\Chat\MessageStore\InMemoryStore; use Symfony\AI\Agent\Chat\MessageStoreInterface; use Symfony\AI\Platform\Message\AssistantMessage; use Symfony\AI\Platform\Message\Message; @@ -28,6 +29,7 @@ #[UsesClass(Message::class)] #[UsesClass(MessageBag::class)] #[UsesClass(TextResult::class)] +#[UsesClass(InMemoryStore::class)] #[Small] final class ChatTest extends TestCase { @@ -165,4 +167,18 @@ public function testItHandlesEmptyMessageStore() $this->assertInstanceOf(AssistantMessage::class, $result); $this->assertSame($assistantContent, $result->content); } + + public function testChatCanReturnCurrentAndSpecificMessageBag() + { + $agent = $this->createMock(AgentInterface::class); + $agent->expects($this->once()) + ->method('call') + ->willReturn(new TextResult('First response')); + + $chat = new Chat($agent, new InMemoryStore()); + $chat->submit(Message::ofUser('First message')); + + $this->assertCount(2, $chat->getCurrentMessageBag()); + $this->assertCount(0, $chat->getMessageBag('foo')); + } } 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..67bcca0ce 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -17,6 +17,12 @@ use Symfony\AI\Agent\AgentInterface; use Symfony\AI\Agent\Attribute\AsInputProcessor; use Symfony\AI\Agent\Attribute\AsOutputProcessor; +use Symfony\AI\Agent\Chat; +use Symfony\AI\Agent\Chat\MessageStore\CacheStore as CacheMessageStore; +use Symfony\AI\Agent\Chat\MessageStore\InMemoryStore as InMemoryMessageStore; +use Symfony\AI\Agent\Chat\MessageStore\SessionStore as SessionMessageStore; +use Symfony\AI\Agent\Chat\MessageStoreInterface; +use Symfony\AI\Agent\ChatInterface; use Symfony\AI\Agent\InputProcessor\SystemPromptInputProcessor; use Symfony\AI\Agent\InputProcessorInterface; use Symfony\AI\Agent\Memory\MemoryInputProcessor; @@ -29,6 +35,8 @@ 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; @@ -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..461fa1501 --- /dev/null +++ b/src/ai-bundle/src/Profiler/TraceableChat.php @@ -0,0 +1,53 @@ + + * + * 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\Agent\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); + } + + public function getCurrentMessageBag(): MessageBag + { + return $this->chat->getCurrentMessageBag(); + } + + public function getMessageBag(string $id): MessageBag + { + return $this->chat->getMessageBag($id); + } + + public function getId(): string + { + return $this->chat->getId(); + } +} diff --git a/src/ai-bundle/src/Profiler/TraceableMessageStore.php b/src/ai-bundle/src/Profiler/TraceableMessageStore.php new file mode 100644 index 000000000..b2f827b3a --- /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\Agent\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..dedb8d6ce 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -2117,6 +2117,41 @@ 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', + ], + ], + ], + ]); + + $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 +2421,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..d9af8d767 --- /dev/null +++ b/src/ai-bundle/tests/Profiler/TraceableChatTest.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\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\Agent\Chat\MessageStore\InMemoryStore; +use Symfony\AI\AiBundle\Profiler\TraceableChat; +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 testIdCanBeRetrieved() + { + $agent = $this->createMock(AgentInterface::class); + + $store = new InMemoryStore('foo'); + $chat = new Chat($agent, $store); + + $traceableChat = new TraceableChat($chat); + + $this->assertSame('foo', $traceableChat->getId()); + } + + 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..ed2bd030b --- /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\Agent\Chat\MessageStore\InMemoryStore; +use Symfony\AI\AiBundle\Profiler\TraceableMessageStore; +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()); + } +} From f57a6938435db9f775340560893c677559f0fa2e Mon Sep 17 00:00:00 2001 From: Guillaume Loulier Date: Mon, 15 Sep 2025 14:12:41 +0200 Subject: [PATCH 2/2] ref(chat): new component && API improvements --- examples/composer.json | 1 + .../misc/persistent-chat-double-agent.php | 24 +++--- examples/misc/persistent-chat.php | 4 +- src/agent/composer.json | 1 + src/agent/src/Chat.php | 74 ------------------ src/agent/tests/AgentTest.php | 6 +- src/ai-bundle/composer.json | 1 + src/ai-bundle/src/AiBundle.php | 12 +-- src/ai-bundle/src/Profiler/TraceableChat.php | 17 +--- .../src/Profiler/TraceableMessageStore.php | 2 +- .../DependencyInjection/AiBundleTest.php | 4 + .../tests/Profiler/TraceableChatTest.php | 14 +--- .../Profiler/TraceableMessageStoreTest.php | 2 +- src/chat/.gitattributes | 8 ++ src/chat/LICENSE | 19 +++++ src/chat/README.md | 24 ++++++ src/chat/composer.json | 60 ++++++++++++++ src/chat/doc/index.rst | 16 ++++ src/chat/phpstan.dist.neon | 12 +++ src/chat/phpunit.xml.dist | 24 ++++++ .../src/Bridge/Local}/CacheStore.php | 24 +++--- .../src/Bridge/Local}/InMemoryStore.php | 21 +++-- .../src/Bridge/Symfony}/SessionStore.php | 26 +++---- src/chat/src/Chat.php | 71 +++++++++++++++++ src/{agent => chat}/src/ChatInterface.php | 12 +-- src/chat/src/Exception/ExceptionInterface.php | 19 +++++ .../Exception/InvalidArgumentException.php | 19 +++++ src/chat/src/Exception/LogicException.php | 19 +++++ src/chat/src/Exception/RuntimeException.php | 19 +++++ src/chat/src/MessageStoreIdentifierTrait.php | 27 +++++++ .../src}/MessageStoreInterface.php | 5 +- .../tests/Bridge/Local}/CacheStoreTest.php | 4 +- .../tests/Bridge/Local}/InMemoryStoreTest.php | 4 +- .../Bridge/Symfony}/SessionStoreTest.php | 12 +-- src/{agent => chat}/tests/ChatTest.php | 78 ++++++++++++++++--- 35 files changed, 486 insertions(+), 199 deletions(-) delete mode 100644 src/agent/src/Chat.php create mode 100644 src/chat/.gitattributes create mode 100644 src/chat/LICENSE create mode 100644 src/chat/README.md create mode 100644 src/chat/composer.json create mode 100644 src/chat/doc/index.rst create mode 100644 src/chat/phpstan.dist.neon create mode 100644 src/chat/phpunit.xml.dist rename src/{agent/src/Chat/MessageStore => chat/src/Bridge/Local}/CacheStore.php (72%) rename src/{agent/src/Chat/MessageStore => chat/src/Bridge/Local}/InMemoryStore.php (62%) rename src/{agent/src/Chat/MessageStore => chat/src/Bridge/Symfony}/SessionStore.php (67%) create mode 100644 src/chat/src/Chat.php rename src/{agent => chat}/src/ChatInterface.php (64%) create mode 100644 src/chat/src/Exception/ExceptionInterface.php create mode 100644 src/chat/src/Exception/InvalidArgumentException.php create mode 100644 src/chat/src/Exception/LogicException.php create mode 100644 src/chat/src/Exception/RuntimeException.php create mode 100644 src/chat/src/MessageStoreIdentifierTrait.php rename src/{agent/src/Chat => chat/src}/MessageStoreInterface.php (87%) rename src/{agent/tests/Chat => chat/tests/Bridge/Local}/CacheStoreTest.php (95%) rename src/{agent/tests/Chat => chat/tests/Bridge/Local}/InMemoryStoreTest.php (95%) rename src/{agent/tests/Chat => chat/tests/Bridge/Symfony}/SessionStoreTest.php (87%) rename src/{agent => chat}/tests/ChatTest.php (68%) 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 index 02c43aa2b..2be31bf06 100644 --- a/examples/misc/persistent-chat-double-agent.php +++ b/examples/misc/persistent-chat-double-agent.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; @@ -26,21 +26,19 @@ $store = new InMemoryStore(); -$firstChat = new Chat($agent, $store); -$secondChat = new Chat($agent, $store); +$chat = new Chat($agent, $store); -$firstChat->initiate(new MessageBag( +$chat->initiate(new MessageBag( Message::forSystem('You are a helpful assistant. You only answer with short sentences.'), -), '_first_chat'); -$secondChat->initiate(new MessageBag( - Message::forSystem('You are a helpful assistant. You only answer with short sentences.'), -), '_second_chat'); +)); + +$forkedChat = $chat->fork('fork'); -$firstChat->submit(Message::ofUser('My name is Christopher.')); -$firstChatMessage = $firstChat->submit(Message::ofUser('What is my name?')); +$chat->submit(Message::ofUser('My name is Christopher.')); +$firstChatMessage = $chat->submit(Message::ofUser('What is my name?')); -$secondChat->submit(Message::ofUser('My name is William.')); -$secondChatMessage = $secondChat->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; 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.php b/src/agent/src/Chat.php deleted file mode 100644 index 0ab2724e2..000000000 --- a/src/agent/src/Chat.php +++ /dev/null @@ -1,74 +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; - -use Symfony\AI\Agent\Chat\MessageStoreInterface; -use Symfony\AI\Platform\Message\AssistantMessage; -use Symfony\AI\Platform\Message\Message; -use Symfony\AI\Platform\Message\MessageBag; -use Symfony\AI\Platform\Message\UserMessage; -use Symfony\AI\Platform\Result\TextResult; - -/** - * @author Christopher Hertel - */ -final class Chat implements ChatInterface -{ - private string $id; - - public function __construct( - private readonly AgentInterface $agent, - private readonly MessageStoreInterface $store, - ) { - $this->id = $this->store->getId(); - } - - public function initiate(MessageBag $messages, ?string $id = null): void - { - $this->id = $id ?? $this->id; - - $this->store->clear(); - $this->store->save($messages, $this->id); - } - - public function submit(UserMessage $message, ?string $id = null): AssistantMessage - { - $messages = $this->store->load($id ?? $this->id); - - $messages->add($message); - $result = $this->agent->call($messages); - - \assert($result instanceof TextResult); - - $assistantMessage = Message::ofAssistant($result->getContent()); - $messages->add($assistantMessage); - - $this->store->save($messages, $this->id); - - return $assistantMessage; - } - - public function getCurrentMessageBag(): MessageBag - { - return $this->store->load($this->id); - } - - public function getMessageBag(string $id): MessageBag - { - return $this->store->load($id); - } - - public function getId(): string - { - return $this->id; - } -} diff --git a/src/agent/tests/AgentTest.php b/src/agent/tests/AgentTest.php index 7e5698911..579807fd2 100644 --- a/src/agent/tests/AgentTest.php +++ b/src/agent/tests/AgentTest.php @@ -433,7 +433,7 @@ public function testGetNameReturnsProvidedName() $this->assertSame($name, $agent->getName()); } - public function testDoubleAgentCanUseSameMessageStore() + public function testMultipleAgentCanUseSameChat() { $platform = $this->createMock(PlatformInterface::class); $platform->method('invoke') @@ -451,10 +451,10 @@ public function testDoubleAgentCanUseSameMessageStore() $firstChat->initiate(new MessageBag( Message::forSystem('You are a helpful assistant. You only answer with short sentences.'), - ), 'foo'); + )); $secondChat->initiate(new MessageBag( Message::forSystem('You are a helpful assistant. You only answer with short sentences.'), - ), 'bar'); + )); $firstChat->submit(new UserMessage(new Text('Hello'))); $secondChat->submit(new UserMessage(new Text('Hello'))); 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/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index 67bcca0ce..0c6318144 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -17,12 +17,6 @@ use Symfony\AI\Agent\AgentInterface; use Symfony\AI\Agent\Attribute\AsInputProcessor; use Symfony\AI\Agent\Attribute\AsOutputProcessor; -use Symfony\AI\Agent\Chat; -use Symfony\AI\Agent\Chat\MessageStore\CacheStore as CacheMessageStore; -use Symfony\AI\Agent\Chat\MessageStore\InMemoryStore as InMemoryMessageStore; -use Symfony\AI\Agent\Chat\MessageStore\SessionStore as SessionMessageStore; -use Symfony\AI\Agent\Chat\MessageStoreInterface; -use Symfony\AI\Agent\ChatInterface; use Symfony\AI\Agent\InputProcessor\SystemPromptInputProcessor; use Symfony\AI\Agent\InputProcessorInterface; use Symfony\AI\Agent\Memory\MemoryInputProcessor; @@ -40,6 +34,12 @@ 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; diff --git a/src/ai-bundle/src/Profiler/TraceableChat.php b/src/ai-bundle/src/Profiler/TraceableChat.php index 461fa1501..35fafe32c 100644 --- a/src/ai-bundle/src/Profiler/TraceableChat.php +++ b/src/ai-bundle/src/Profiler/TraceableChat.php @@ -11,7 +11,7 @@ namespace Symfony\AI\AiBundle\Profiler; -use Symfony\AI\Agent\ChatInterface; +use Symfony\AI\Chat\ChatInterface; use Symfony\AI\Platform\Message\AssistantMessage; use Symfony\AI\Platform\Message\MessageBag; use Symfony\AI\Platform\Message\UserMessage; @@ -35,19 +35,4 @@ public function submit(UserMessage $message, ?string $id = null): AssistantMessa { return $this->chat->submit($message, $id); } - - public function getCurrentMessageBag(): MessageBag - { - return $this->chat->getCurrentMessageBag(); - } - - public function getMessageBag(string $id): MessageBag - { - return $this->chat->getMessageBag($id); - } - - public function getId(): string - { - return $this->chat->getId(); - } } diff --git a/src/ai-bundle/src/Profiler/TraceableMessageStore.php b/src/ai-bundle/src/Profiler/TraceableMessageStore.php index b2f827b3a..f7adb4990 100644 --- a/src/ai-bundle/src/Profiler/TraceableMessageStore.php +++ b/src/ai-bundle/src/Profiler/TraceableMessageStore.php @@ -11,7 +11,7 @@ namespace Symfony\AI\AiBundle\Profiler; -use Symfony\AI\Agent\Chat\MessageStoreInterface; +use Symfony\AI\Chat\MessageStoreInterface; use Symfony\AI\Platform\Message\MessageBag; /** diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php index dedb8d6ce..c0f8008fc 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -2142,6 +2142,10 @@ public function testAgentsWithChatCanBeDefined() 'agent' => 'another_agent', 'message_store' => 'cache', ], + 'second' => [ + 'agent' => 'another_agent', + 'message_store' => 'cache', + ], ], ], ]); diff --git a/src/ai-bundle/tests/Profiler/TraceableChatTest.php b/src/ai-bundle/tests/Profiler/TraceableChatTest.php index d9af8d767..0182d1517 100644 --- a/src/ai-bundle/tests/Profiler/TraceableChatTest.php +++ b/src/ai-bundle/tests/Profiler/TraceableChatTest.php @@ -16,8 +16,8 @@ use PHPUnit\Framework\TestCase; use Symfony\AI\Agent\AgentInterface; use Symfony\AI\Agent\Chat; -use Symfony\AI\Agent\Chat\MessageStore\InMemoryStore; 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; @@ -28,18 +28,6 @@ #[UsesClass(TextResult::class)] final class TraceableChatTest extends TestCase { - public function testIdCanBeRetrieved() - { - $agent = $this->createMock(AgentInterface::class); - - $store = new InMemoryStore('foo'); - $chat = new Chat($agent, $store); - - $traceableChat = new TraceableChat($chat); - - $this->assertSame('foo', $traceableChat->getId()); - } - public function testCurrentMessageBagCanBeRetrieved() { $agent = $this->createMock(AgentInterface::class); diff --git a/src/ai-bundle/tests/Profiler/TraceableMessageStoreTest.php b/src/ai-bundle/tests/Profiler/TraceableMessageStoreTest.php index ed2bd030b..8eaf82b1e 100644 --- a/src/ai-bundle/tests/Profiler/TraceableMessageStoreTest.php +++ b/src/ai-bundle/tests/Profiler/TraceableMessageStoreTest.php @@ -14,8 +14,8 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; -use Symfony\AI\Agent\Chat\MessageStore\InMemoryStore; 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; 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 72% rename from src/agent/src/Chat/MessageStore/CacheStore.php rename to src/chat/src/Bridge/Local/CacheStore.php index a228dbbcf..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 $id = '_message_store_cache', + 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, ?string $id = null): void { - $item = $this->cache->getItem($id ?? $this->id); + $item = $this->cache->getItem($id ?? $this->getId()); $item->set($messages); $item->expiresAfter($this->ttl); @@ -43,18 +48,13 @@ public function save(MessageBag $messages, ?string $id = null): void public function load(?string $id = null): MessageBag { - $item = $this->cache->getItem($id ?? $this->id); + $item = $this->cache->getItem($id ?? $this->getId()); return $item->isHit() ? $item->get() : new MessageBag(); } public function clear(?string $id = null): void { - $this->cache->deleteItem($id ?? $this->id); - } - - public function getId(): string - { - return $this->id; + $this->cache->deleteItem($id ?? $this->getId()); } } diff --git a/src/agent/src/Chat/MessageStore/InMemoryStore.php b/src/chat/src/Bridge/Local/InMemoryStore.php similarity index 62% rename from src/agent/src/Chat/MessageStore/InMemoryStore.php rename to src/chat/src/Bridge/Local/InMemoryStore.php index 7f9d90975..f82c1a13e 100644 --- a/src/agent/src/Chat/MessageStore/InMemoryStore.php +++ b/src/chat/src/Bridge/Local/InMemoryStore.php @@ -9,9 +9,10 @@ * file that was distributed with this source code. */ -namespace Symfony\AI\Agent\Chat\MessageStore; +namespace Symfony\AI\Chat\Bridge\Local; -use Symfony\AI\Agent\Chat\MessageStoreInterface; +use Symfony\AI\Chat\MessageStoreIdentifierTrait; +use Symfony\AI\Chat\MessageStoreInterface; use Symfony\AI\Platform\Message\MessageBag; /** @@ -19,33 +20,31 @@ */ final class InMemoryStore implements MessageStoreInterface { + use MessageStoreIdentifierTrait; + /** * @var MessageBag[] */ private array $messageBags; public function __construct( - private readonly string $id = '_message_store_memory', + string $id = '_message_store_memory', ) { + $this->setId($id); } public function save(MessageBag $messages, ?string $id = null): void { - $this->messageBags[$id ?? $this->id] = $messages; + $this->messageBags[$id ?? $this->getId()] = $messages; } public function load(?string $id = null): MessageBag { - return $this->messageBags[$id ?? $this->id] ?? new MessageBag(); + return $this->messageBags[$id ?? $this->getId()] ?? new MessageBag(); } public function clear(?string $id = null): void { - $this->messageBags[$id ?? $this->id] = new MessageBag(); - } - - public function getId(): string - { - return $this->id; + $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 67% rename from src/agent/src/Chat/MessageStore/SessionStore.php rename to src/chat/src/Bridge/Symfony/SessionStore.php index 1bfc07c8b..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,38 +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 $id = '_message_store_session', + 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, ?string $id = null): void { - $this->session->set($id ?? $this->id, $messages); + $this->session->set($id ?? $this->getId(), $messages); } public function load(?string $id = null): MessageBag { - return $this->session->get($id ?? $this->id, new MessageBag()); + return $this->session->get($id ?? $this->getId(), new MessageBag()); } public function clear(?string $id = null): void { - $this->session->remove($id ?? $this->id); - } - - public function getId(): string - { - return $this->id; + $this->session->remove($id ?? $this->getId()); } } diff --git a/src/chat/src/Chat.php b/src/chat/src/Chat.php new file mode 100644 index 000000000..a9adcca8a --- /dev/null +++ b/src/chat/src/Chat.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; + +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; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\AI\Platform\Result\TextResult; + +/** + * @author Christopher Hertel + */ +final readonly class Chat implements ChatInterface +{ + public function __construct( + private AgentInterface $agent, + private MessageStoreInterface $store, + ) { + } + + public function initiate(MessageBag $messages): void + { + $this->store->clear(); + $this->store->save($messages); + } + + public function submit(UserMessage $message): AssistantMessage + { + $messages = $this->store->load(); + + $messages->add($message); + $result = $this->agent->call($messages); + + \assert($result instanceof TextResult); + + $assistantMessage = Message::ofAssistant($result->getContent()); + $messages->add($assistantMessage); + + $this->store->save($messages); + + 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 64% rename from src/agent/src/ChatInterface.php rename to src/chat/src/ChatInterface.php index 54f8a473c..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; @@ -21,16 +21,12 @@ */ interface ChatInterface { - public function initiate(MessageBag $messages, ?string $id = null): void; + public function initiate(MessageBag $messages): void; /** * @throws ExceptionInterface When the chat submission fails due to agent errors */ - public function submit(UserMessage $message, ?string $id = null): AssistantMessage; + public function submit(UserMessage $message): AssistantMessage; - public function getCurrentMessageBag(): MessageBag; - - public function getMessageBag(string $id): MessageBag; - - public function getId(): string; + 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 87% rename from src/agent/src/Chat/MessageStoreInterface.php rename to src/chat/src/MessageStoreInterface.php index 38abd6b9b..f8df8f3bd 100644 --- a/src/agent/src/Chat/MessageStoreInterface.php +++ b/src/chat/src/MessageStoreInterface.php @@ -9,9 +9,8 @@ * file that was distributed with this source code. */ -namespace Symfony\AI\Agent\Chat; +namespace Symfony\AI\Chat; -use Symfony\AI\Agent\ChatInterface; use Symfony\AI\Platform\Message\MessageBag; /** @@ -27,6 +26,4 @@ public function save(MessageBag $messages, ?string $id = null): void; public function load(?string $id = null): MessageBag; public function clear(?string $id = null): void; - - public function getId(): string; } diff --git a/src/agent/tests/Chat/CacheStoreTest.php b/src/chat/tests/Bridge/Local/CacheStoreTest.php similarity index 95% rename from src/agent/tests/Chat/CacheStoreTest.php rename to src/chat/tests/Bridge/Local/CacheStoreTest.php index 2fe2fa57d..803ccb539 100644 --- a/src/agent/tests/Chat/CacheStoreTest.php +++ b/src/chat/tests/Bridge/Local/CacheStoreTest.php @@ -9,12 +9,12 @@ * file that was distributed with this source code. */ -namespace Symfony\AI\Agent\Tests\Chat; +namespace Symfony\AI\Chat\Tests\Bridge\Local; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; -use Symfony\AI\Agent\Chat\MessageStore\CacheStore; +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; diff --git a/src/agent/tests/Chat/InMemoryStoreTest.php b/src/chat/tests/Bridge/Local/InMemoryStoreTest.php similarity index 95% rename from src/agent/tests/Chat/InMemoryStoreTest.php rename to src/chat/tests/Bridge/Local/InMemoryStoreTest.php index 98a8df664..d023b6417 100644 --- a/src/agent/tests/Chat/InMemoryStoreTest.php +++ b/src/chat/tests/Bridge/Local/InMemoryStoreTest.php @@ -9,12 +9,12 @@ * file that was distributed with this source code. */ -namespace Symfony\AI\Agent\Tests\Chat; +namespace Symfony\AI\Chat\Tests\Bridge\Local; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; -use Symfony\AI\Agent\Chat\MessageStore\InMemoryStore; +use Symfony\AI\Chat\Bridge\Local\InMemoryStore; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; diff --git a/src/agent/tests/Chat/SessionStoreTest.php b/src/chat/tests/Bridge/Symfony/SessionStoreTest.php similarity index 87% rename from src/agent/tests/Chat/SessionStoreTest.php rename to src/chat/tests/Bridge/Symfony/SessionStoreTest.php index 0ff41293a..c74bc30cc 100644 --- a/src/agent/tests/Chat/SessionStoreTest.php +++ b/src/chat/tests/Bridge/Symfony/SessionStoreTest.php @@ -9,12 +9,12 @@ * file that was distributed with this source code. */ -namespace Symfony\AI\Agent\Tests\Chat; +namespace Symfony\AI\Chat\Tests\Bridge\Symfony; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; -use Symfony\AI\Agent\Chat\MessageStore\SessionStore; +use Symfony\AI\Chat\Bridge\Symfony\SessionStore; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; use Symfony\Component\HttpFoundation\Request; @@ -44,7 +44,7 @@ public function testItCanStore() $store = new SessionStore($requestStack); $store->save($messageBag); - $this->assertCount(1, $store->load('_message_store_session')); + $this->assertCount(1, $store->load()); } public function testItCanStoreMultipleMessageBags() @@ -70,7 +70,7 @@ public function testItCanStoreMultipleMessageBags() $this->assertCount(1, $store->load('foo')); $this->assertCount(1, $store->load('bar')); - $this->assertCount(0, $store->load('_message_store_session')); + $this->assertCount(0, $store->load()); } public function testItCanClear() @@ -91,10 +91,10 @@ public function testItCanClear() $store = new SessionStore($requestStack); $store->save($bag); - $this->assertCount(2, $store->load('_message_store_session')); + $this->assertCount(2, $store->load()); $store->clear(); - $this->assertCount(0, $store->load('_message_store_session')); + $this->assertCount(0, $store->load()); } } diff --git a/src/agent/tests/ChatTest.php b/src/chat/tests/ChatTest.php similarity index 68% rename from src/agent/tests/ChatTest.php rename to src/chat/tests/ChatTest.php index f36770831..be6f86dc9 100644 --- a/src/agent/tests/ChatTest.php +++ b/src/chat/tests/ChatTest.php @@ -9,27 +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\MessageStore\InMemoryStore; -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 { @@ -168,17 +175,64 @@ public function testItHandlesEmptyMessageStore() $this->assertSame($assistantContent, $result->content); } - public function testChatCanReturnCurrentAndSpecificMessageBag() + public function testChatCanUseAnotherAgentOnceInitialized() { - $agent = $this->createMock(AgentInterface::class); - $agent->expects($this->once()) - ->method('call') - ->willReturn(new TextResult('First response')); + $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); - $chat = new Chat($agent, new InMemoryStore()); + $firstAgent = new Agent($platform, $model); + + $store = new InMemoryStore(); + + $chat = new Chat($firstAgent, $store); $chat->submit(Message::ofUser('First message')); - $this->assertCount(2, $chat->getCurrentMessageBag()); - $this->assertCount(0, $chat->getMessageBag('foo')); + $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')); } }