diff --git a/examples/chat/persistent-chat-cache.php b/examples/chat/persistent-chat-cache.php new file mode 100644 index 000000000..9c9178de1 --- /dev/null +++ b/examples/chat/persistent-chat-cache.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Chat\Bridge\Local\CacheStore; +use Symfony\AI\Chat\Chat; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\Component\Cache\Adapter\ArrayAdapter; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); + +$store = new CacheStore(new ArrayAdapter(), 'chat'); +$store->setup(); + +$agent = new Agent($platform, 'gpt-4o-mini', logger: logger()); +$chat = new Chat($agent, $store); + +$messages = new MessageBag( + Message::forSystem('You are a helpful assistant. You only answer with short sentences.'), +); + +$chat->initiate($messages); +$chat->submit(Message::ofUser('My name is Christopher.')); +$message = $chat->submit(Message::ofUser('What is my name?')); + +echo $message->content.\PHP_EOL; diff --git a/examples/chat/persistent-chat-session.php b/examples/chat/persistent-chat-session.php new file mode 100644 index 000000000..b0d4f7eb2 --- /dev/null +++ b/examples/chat/persistent-chat-session.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Chat\Bridge\HttpFoundation\SessionStore; +use Symfony\AI\Chat\Chat; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +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; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); + +$request = Request::create('/'); +$request->setSession(new Session(new MockArraySessionStorage())); + +$requestStack = new RequestStack(); +$requestStack->push($request); + +$store = new SessionStore($requestStack, 'chat'); +$store->setup(); + +$agent = new Agent($platform, 'gpt-4o-mini', logger: logger()); +$chat = new Chat($agent, $store); + +$messages = new MessageBag( + Message::forSystem('You are a helpful assistant. You only answer with short sentences.'), +); + +$chat->initiate($messages); +$chat->submit(Message::ofUser('My name is Christopher.')); +$message = $chat->submit(Message::ofUser('What is my name?')); + +echo $message->content.\PHP_EOL; diff --git a/examples/composer.json b/examples/composer.json index 1d214c1c0..74d5d33a5 100644 --- a/examples/composer.json +++ b/examples/composer.json @@ -29,6 +29,7 @@ "symfony/event-dispatcher": "^7.3|^8.0", "symfony/filesystem": "^7.3|^8.0", "symfony/finder": "^7.3|^8.0", + "symfony/http-foundation": "^7.3|^8.0", "symfony/process": "^7.3|^8.0", "symfony/var-dumper": "^7.3|^8.0" }, diff --git a/examples/openai/agent-as-tool.php b/examples/openai/agent-as-tool.php index 134d6d9aa..978fbfbd1 100644 --- a/examples/openai/agent-as-tool.php +++ b/examples/openai/agent-as-tool.php @@ -13,10 +13,10 @@ use Symfony\AI\Agent\InputProcessor\SystemPromptInputProcessor; use Symfony\AI\Agent\Toolbox\AgentProcessor; use Symfony\AI\Agent\Toolbox\Tool\Agent as AgentTool; +use Symfony\AI\Agent\Toolbox\Toolbox; use Symfony\AI\Agent\Toolbox\ToolFactory\ChainFactory; use Symfony\AI\Agent\Toolbox\ToolFactory\MemoryToolFactory; use Symfony\AI\Agent\Toolbox\ToolFactory\ReflectionToolFactory; -use Symfony\AI\Agent\Toolbox\Toolbox; use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; diff --git a/src/chat/AGENTS.md b/src/chat/AGENTS.md index 845fee746..0d7bc6b71 100644 --- a/src/chat/AGENTS.md +++ b/src/chat/AGENTS.md @@ -12,6 +12,7 @@ Library for building chats with agents using messages. Built on Platform and Age - **Chat** (`src/Chat.php`): Main orchestration class - **ChatInterface**: Contract for implementations - **MessageStoreInterface** High-level conversation storage interface +- **ManagedStoreInterface** High-level store management interface ### Key Features - **Bridge** (`src/Bridge/`): Storage capacity for messages and conversations diff --git a/src/chat/CLAUDE.md b/src/chat/CLAUDE.md index 7eff813da..81321bf2a 100644 --- a/src/chat/CLAUDE.md +++ b/src/chat/CLAUDE.md @@ -12,6 +12,7 @@ Library for building chats with agents using messages. Built on Platform and Age - **Chat** (`src/Chat.php`): Main orchestration class - **ChatInterface**: Contract for implementations - **MessageStoreInterface** High-level conversation storage interface +- **ManagedStoreInterface** High-level store management interface ### Key Features - **Bridge** (`src/Bridge/`): Storage capacity for messages and conversations diff --git a/src/chat/doc/index.rst b/src/chat/doc/index.rst index d196249a3..90fd1e6f1 100644 --- a/src/chat/doc/index.rst +++ b/src/chat/doc/index.rst @@ -34,3 +34,53 @@ with a ``Symfony\AI\Agent\AgentInterface`` and a ``Symfony\AI\Chat\MessageStoreI $chat->submit(Message::ofUser('Hello')); + +Implementing a Bridge +--------------------- + +The main extension points of the Chat component is the ``Symfony\AI\Chat\MessageStoreInterface``, that defines the methods +for adding messages to the message store, and returning the messages from a store. + +This leads to a store implementing two methods:: + + use Symfony\AI\Store\MessageStoreInterface; + + class MyCustomStore implements MessageStoreInterface + { + public function save(MessageBag $messages): void + { + // Implementation to add a message bag to the store + } + + public function load(): MessageBag + { + // Implementation to return a message bag from the store + } + } + +Managing a store +---------------- + +Some store might requires to create table, indexes and so on before storing messages, +the ``Symfony\AI\Chat\ManagedStoreInterface`` defines the methods +to setup and drop the store. + +This leads to a store implementing two methods:: + + use Symfony\AI\Store\ManagedStoreInterface; + use Symfony\AI\Store\MessageStoreInterface; + + class MyCustomStore implements ManagedStoreInterface, MessageStoreInterface + { + # ... + + public function setup(array $options = []): void + { + // Implementation to create the store + } + + public function drop(): void + { + // Implementation to drop the store (and related messages) + } + } diff --git a/src/chat/src/Bridge/HttpFoundation/SessionStore.php b/src/chat/src/Bridge/HttpFoundation/SessionStore.php index 32c11f4dd..1e9994f4c 100644 --- a/src/chat/src/Bridge/HttpFoundation/SessionStore.php +++ b/src/chat/src/Bridge/HttpFoundation/SessionStore.php @@ -12,6 +12,7 @@ namespace Symfony\AI\Chat\Bridge\HttpFoundation; use Symfony\AI\Agent\Exception\RuntimeException; +use Symfony\AI\Chat\ManagedStoreInterface; use Symfony\AI\Chat\MessageStoreInterface; use Symfony\AI\Platform\Message\MessageBag; use Symfony\Component\HttpFoundation\RequestStack; @@ -20,7 +21,7 @@ /** * @author Christopher Hertel */ -final readonly class SessionStore implements MessageStoreInterface +final readonly class SessionStore implements ManagedStoreInterface, MessageStoreInterface { private SessionInterface $session; @@ -31,9 +32,15 @@ public function __construct( 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 setup(array $options = []): void + { + $this->session->set($this->sessionKey, new MessageBag()); + } + public function save(MessageBag $messages): void { $this->session->set($this->sessionKey, $messages); @@ -44,7 +51,7 @@ public function load(): MessageBag return $this->session->get($this->sessionKey, new MessageBag()); } - public function clear(): void + public function drop(): void { $this->session->remove($this->sessionKey); } diff --git a/src/chat/src/Bridge/Local/CacheStore.php b/src/chat/src/Bridge/Local/CacheStore.php index 3d8ae4805..4dc59af9d 100644 --- a/src/chat/src/Bridge/Local/CacheStore.php +++ b/src/chat/src/Bridge/Local/CacheStore.php @@ -13,13 +13,14 @@ use Psr\Cache\CacheItemPoolInterface; use Symfony\AI\Agent\Exception\RuntimeException; +use Symfony\AI\Chat\ManagedStoreInterface; use Symfony\AI\Chat\MessageStoreInterface; use Symfony\AI\Platform\Message\MessageBag; /** * @author Christopher Hertel */ -final readonly class CacheStore implements MessageStoreInterface +final readonly class CacheStore implements ManagedStoreInterface, MessageStoreInterface { public function __construct( private CacheItemPoolInterface $cache, @@ -31,6 +32,16 @@ public function __construct( } } + public function setup(array $options = []): void + { + $item = $this->cache->getItem($this->cacheKey); + + $item->set(new MessageBag()); + $item->expiresAfter($this->ttl); + + $this->cache->save($item); + } + public function save(MessageBag $messages): void { $item = $this->cache->getItem($this->cacheKey); @@ -48,7 +59,7 @@ public function load(): MessageBag return $item->isHit() ? $item->get() : new MessageBag(); } - public function clear(): void + public function drop(): void { $this->cache->deleteItem($this->cacheKey); } diff --git a/src/chat/src/Bridge/Local/InMemoryStore.php b/src/chat/src/Bridge/Local/InMemoryStore.php index e23777efa..03fb986b2 100644 --- a/src/chat/src/Bridge/Local/InMemoryStore.php +++ b/src/chat/src/Bridge/Local/InMemoryStore.php @@ -11,16 +11,22 @@ namespace Symfony\AI\Chat\Bridge\Local; +use Symfony\AI\Chat\ManagedStoreInterface; use Symfony\AI\Chat\MessageStoreInterface; use Symfony\AI\Platform\Message\MessageBag; /** * @author Christopher Hertel */ -final class InMemoryStore implements MessageStoreInterface +final class InMemoryStore implements ManagedStoreInterface, MessageStoreInterface { private MessageBag $messages; + public function setup(array $options = []): void + { + $this->messages = new MessageBag(); + } + public function save(MessageBag $messages): void { $this->messages = $messages; @@ -31,7 +37,7 @@ public function load(): MessageBag return $this->messages ?? new MessageBag(); } - public function clear(): void + public function drop(): void { $this->messages = new MessageBag(); } diff --git a/src/chat/src/Chat.php b/src/chat/src/Chat.php index a4e7f54af..6673e93d2 100644 --- a/src/chat/src/Chat.php +++ b/src/chat/src/Chat.php @@ -25,13 +25,13 @@ { public function __construct( private AgentInterface $agent, - private MessageStoreInterface $store, + private MessageStoreInterface&ManagedStoreInterface $store, ) { } public function initiate(MessageBag $messages): void { - $this->store->clear(); + $this->store->drop(); $this->store->save($messages); } diff --git a/src/chat/src/ManagedStoreInterface.php b/src/chat/src/ManagedStoreInterface.php new file mode 100644 index 000000000..ecf0929dd --- /dev/null +++ b/src/chat/src/ManagedStoreInterface.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat; + +interface ManagedStoreInterface +{ + /** + * @param array $options + */ + public function setup(array $options = []): void; + + public function drop(): void; +} diff --git a/src/chat/src/MessageStoreInterface.php b/src/chat/src/MessageStoreInterface.php index ec0833e7c..2fd89880e 100644 --- a/src/chat/src/MessageStoreInterface.php +++ b/src/chat/src/MessageStoreInterface.php @@ -21,6 +21,4 @@ interface MessageStoreInterface public function save(MessageBag $messages): void; public function load(): MessageBag; - - public function clear(): void; } diff --git a/src/chat/tests/ChatTest.php b/src/chat/tests/ChatTest.php index 9522fdbd8..078d92465 100644 --- a/src/chat/tests/ChatTest.php +++ b/src/chat/tests/ChatTest.php @@ -14,8 +14,8 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\AI\Agent\AgentInterface; +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; @@ -24,13 +24,13 @@ final class ChatTest extends TestCase { private AgentInterface&MockObject $agent; - private MessageStoreInterface&MockObject $store; + private InMemoryStore $store; private Chat $chat; protected function setUp(): void { $this->agent = $this->createMock(AgentInterface::class); - $this->store = $this->createMock(MessageStoreInterface::class); + $this->store = new InMemoryStore(); $this->chat = new Chat($this->agent, $this->store); } @@ -38,28 +38,18 @@ public function testItInitiatesChatByClearingAndSavingMessages() { $messages = $this->createMock(MessageBag::class); - $this->store->expects($this->once()) - ->method('clear'); - - $this->store->expects($this->once()) - ->method('save') - ->with($messages); - $this->chat->initiate($messages); + + $this->assertCount(0, $this->store->load()); } public function testItSubmitsUserMessageAndReturnsAssistantMessage() { $userMessage = Message::ofUser('Hello, how are you?'); - $existingMessages = new MessageBag(); $assistantContent = 'I am doing well, thank you!'; $textResult = new TextResult($assistantContent); - $this->store->expects($this->once()) - ->method('load') - ->willReturn($existingMessages); - $this->agent->expects($this->once()) ->method('call') ->with($this->callback(function (MessageBag $messages) use ($userMessage) { @@ -69,22 +59,11 @@ public function testItSubmitsUserMessageAndReturnsAssistantMessage() })) ->willReturn($textResult); - $this->store->expects($this->once()) - ->method('save') - ->with($this->callback(function (MessageBag $messages) use ($userMessage, $assistantContent) { - $messagesArray = $messages->getMessages(); - $lastTwo = \array_slice($messagesArray, -2); - - return 2 === \count($lastTwo) - && $lastTwo[0] === $userMessage - && $lastTwo[1] instanceof AssistantMessage - && $lastTwo[1]->content === $assistantContent; - })); - $result = $this->chat->submit($userMessage); $this->assertInstanceOf(AssistantMessage::class, $result); $this->assertSame($assistantContent, $result->content); + $this->assertCount(2, $this->store->load()); } public function testItAppendsMessagesToExistingConversation() @@ -101,45 +80,24 @@ public function testItAppendsMessagesToExistingConversation() $textResult = new TextResult($newAssistantContent); - $this->store->expects($this->once()) - ->method('load') - ->willReturn($existingMessages); - $this->agent->expects($this->once()) ->method('call') - ->with($this->callback(function (MessageBag $messages) { - $messagesArray = $messages->getMessages(); - - return 3 === \count($messagesArray); - })) ->willReturn($textResult); - $this->store->expects($this->once()) - ->method('save') - ->with($this->callback(function (MessageBag $messages) { - $messagesArray = $messages->getMessages(); - - return 4 === \count($messagesArray); - })); - $result = $this->chat->submit($newUserMessage); $this->assertInstanceOf(AssistantMessage::class, $result); $this->assertSame($newAssistantContent, $result->content); + $this->assertCount(2, $this->store->load()); } public function testItHandlesEmptyMessageStore() { $userMessage = Message::ofUser('First message'); - $emptyMessages = new MessageBag(); $assistantContent = 'First response'; $textResult = new TextResult($assistantContent); - $this->store->expects($this->once()) - ->method('load') - ->willReturn($emptyMessages); - $this->agent->expects($this->once()) ->method('call') ->with($this->callback(function (MessageBag $messages) { @@ -149,12 +107,10 @@ public function testItHandlesEmptyMessageStore() })) ->willReturn($textResult); - $this->store->expects($this->once()) - ->method('save'); - $result = $this->chat->submit($userMessage); $this->assertInstanceOf(AssistantMessage::class, $result); $this->assertSame($assistantContent, $result->content); + $this->assertCount(2, $this->store->load()); } }