Skip to content

Conversation

Guikingone
Copy link
Contributor

@Guikingone Guikingone commented Aug 3, 2025

Q A
Bug fix? no
New feature? yes
Docs? no
Issues Improve #239
License MIT

Hi 👋🏻

A small POC for the "session" that we discussed in #239, this PR is a WIP (and even more than a WIP, an open discussion 😅 ).

Here's the current state:

  • Agent refactoring - Done
  • Chat refactoring - Not needed IMO
  • Message store refactoring - Tricky part

If we want to store "multiple sessions" depending on the agents, we can't interact with the bag (as it's not persisted), the only way is to do it at the store level, thing is, how?

Well, one way is to "initialize" the store with a session, the question is: Is it mandatory every time we initialize a store?

If so then we can just change the constructor of InMemoryStore and it's done, easy, simple, not much work, thing is, we'll be blocked if we want to handle multiple agents at the same time in the same store (parallel handling, etc).

So here's my take, not perfect, open to discuss about it, it's a POC after all 😄

PS: At the end, this API calls will be hidden when using the bundle, the main concern is the "OOP without Sf" usage.
PS II: The interface is inspired by the one from Cache for tags, the behavior is not the same but the idea still there.

EDIT (9/3/2025)

Here's the Profiler panel (before submitting):

Capture d’écran 2025-09-03 à 09 40 19

Here's the Profiler panel (after submitting):

Capture d’écran 2025-09-03 à 09 41 37

@chr-hertel
Copy link
Member

I'd say it's not the agent, that needs the ID, but the MessageBag. The message bag represents the conversations of a specific user/interaction. It is the subject we want to persist.

@Guikingone
Copy link
Contributor Author

Agree but you need to define the id of the agent at the agent level then pass it into the message bag, without it, you'll be not able to "tie together" both the agent and the messages 🤔

@chr-hertel
Copy link
Member

@Guikingone but you're referring to differentiating various agent configurations not instances of the same agent per User, right?

Would a name of the agent solve that concern?

@Guikingone
Copy link
Contributor Author

My POV is the following:

  • Each agent must have a unique identifier (UUID, ULID, other?) used to store messages related to this agent

Regarding the technical implementation, to be fair, I have two approaches in mind:

  • Triggering an UUID at the agent level, this UUID is used by every chat to identify the message bag linked to this agent.
  • Triggering the UUID at the chat level then store it with the message bag.

The first approach seems "the way to do it" in my mind, each agent is unique, we might want to reuse messages linked to this one, it should "store" its identifier.

The second one is tied to the chat in a way that any agent that "connect" to this chat can retrieve the chat history, IMO, that's an issue as we sometimes prefer to link to a specific agent (due to messages concerns, etc).

Either way, I just pushed a "new version" of the POC, feel free to explore it (it doesn't change the "public API") 🙂

@chr-hertel
Copy link
Member

chr-hertel commented Aug 7, 2025

Why this doesn't make sense to me is because I still see the Agent as a service - which might potentially change, but right now it is treated like that. It's also how the bundle treats it - meaning one instance of the agent is shared per Symfony kernel - having the UUID in the agent also means that it changes with every kernel boot.

On top we already have a state object, that we can persist and hydrate - which is the MessageBag. I will try your POC but just can't follow your reasoning/design decision here right away.

@Guikingone
Copy link
Contributor Author

Ah, got it, didn't thought about the kernel reboot (especially during container compilation), I was focused on the approach via the examples, not the "framework usage" one.

Of course, it make sense now, I was wrong, completely wrong, I'll check the idea of having the UUID at the bag level, it should be easier to handle in the store, etc.

Sorry for the dead end discussion, wasn't on the "right path" 🙁

@chr-hertel
Copy link
Member

@Guikingone nooo, all good, you're on a streak anyhow :D

@Guikingone
Copy link
Contributor Author

@chr-hertel I just pushed an update with your ideas implemented along with the configuration using the bundle, the tests are not working (due to various issues on the mock usage) but If you have time to see if it fits your vision of the "identifier -> chat -> agent" usage, I'll be glad to have feedbacks on it 🙂

@Guikingone Guikingone changed the title [Agent] Introduce "session" - POC [Agent] Add MessageBag "session" Aug 13, 2025
@chr-hertel
Copy link
Member

I like this way more now, thanks 👍

The only thing that turns be off at this point is the state awareness in the Chat but that's my fault writing the public function submit(UserMessage $message): AssistantMessage in the first place - since this is requiring you to go with a stateful class there ...

I'm fine merging this when the pipeline is green - just sharing my concerns in case it resonates with you and you have an idea.

Thanks already!

@Guikingone
Copy link
Contributor Author

Hum, a solution might be to use an identifier at the Chat level, this way, the MessageBag is not responsible for identifying itself at the store level, only the Chat know which messages belong to which Agent (the agent doesn't even know that messages are tracked) and we can reuse the identifier of the Chat no matter the agent used by it.

If we move the identifier to MessageBag, we're facing the case where we must store it "locally" in order to retrieve it later like the current approach 🤔

@chr-hertel
Copy link
Member

what do you think about this?

$agent = new Agent($platform, $llm, logger: logger());
$store = new InMemoryStore();
$chat = new Chat($agent, $store);

$messagesOne = new MessageBag(
    Message::forSystem('You are a helpful assistant. You only answer with short sentences.'))
);
$messagesTwo = clone $messagesOne; // copies the messages but changes the ID

$chat->initiate($messagesOne);
// Calling "$chat->initiate($messagesOne);" should throw an exception "already initiated" or something - meaning present in the store
$chat->initiate($messagesTwo);

$firstMessage = $chat->submit($messagesOne->getId(), Message::ofUser('My name is Christopher.'));
$secondMessage = $chat->submit($messagesTwo->getId(), Message::ofUser('My name is Christopher.'));

echo $firstMessage->content.\PHP_EOL;
echo $secondMessage->content.\PHP_EOL;

so basically extending the chat->submit by the ID of the initialized messagebag it is referring to

@Guikingone
Copy link
Contributor Author

Hum, not convinced about the final API (especially the fact that we need to pass the id), here's the reasoning behind moving the identifier to Chat instead of MessageBag:

  • Without Chat, there's no incentives to store messages, even when you're using the Platform, no need to create a chain of messages, it's a "one shot situation".
  • MessageBag can be compared to ParameterBag, it's only a "ValueObject / DTO" that "encapsulate" the logic of interacting with messages, there's is no logic about storing, filtering and so on (apart from method that returns facts but we're not altering the bag), when we're dealing with ParameterBag, it does not identify itself against the container, the container creates it and we can access it inside the application without taking care about the Request or the service we're in.
  • MessageBag is either returned or created from messages stores, if the UUID isn't found, we should instantiate a new one, problem is, if the identifier is at the bag level, when creating a new one, we're loosing the identifier as it's created in the constructor, we can add a new constructor argument but is there any gains here? Open to debate.
  • Moving the identifier at the Chat level allows to inject the Chat inside multiple service (think a RAG one, a controller, an API one, etc) without interfering with the bag, the main issue is that if Chat is created by the container, each time we clear it, the identifier will change (due to constructor calls).

There could be an alternative solution, it's just an idea, open to debate:

What if we're moving the messages identifier at the message store level, like the vector store ones?

Actually, we're allowing users to define a key for each store instances while storing vectors, this approach allows to keep the "chain of vectors" even if the container is cleared / built again, if my store is defining a key foo and vectors are stored (let say in CacheStore), even if I clear the cache, my store is able to retrieve existing vectors, no matter how much time I clear it.

If a user wants to use the same "implementation" but for another agent, he can define a new message store at the configuration level and "split" the storage for each agent, we're already doing this for vectors so IMHO, that's not a "technical issue" but a "design / developer experience" one 🤔

@chr-hertel
Copy link
Member

a) next to the name the comparison with ParameterBag doesn't work for me - we're not talking VO here, but a persistent object - compare it to an entity.

b) the chat is an integration service - it takes an agent and a store and some state. it should not track and have state itself.

but will give it another read and thought later. maybe this feature isn't worth the trouble as well ... would rather see more sophisticated pattern in the agent component 🤔

@Guikingone
Copy link
Contributor Author

a) next to the name the comparison with ParameterBag doesn't work for me - we're not talking VO here, but a persistent object - compare it to an entity.

Well, MessageBag is actually used as a persistent objet but is it really required? At the end, we want to store the messages, is the bag really necessary when storing them? 🤔

b) the chat is an integration service - it takes an agent and a store and some state. it should not track and have state itself.

Yes, agree, that's why I talked about the id at the message store level (like vector stores), I found the chat approach weird while writing the comment 😅

@OskarStark
Copy link
Contributor

I thinking is, how would you otherwise ensure the correct order of the stored messages?

Edit: hmm the id is time based, so it is possible 🤔

@welcoMattic
Copy link
Member

welcoMattic commented Aug 20, 2025

I've discuss with @Guikingone, because I've a use case preparing my talk for API Platform Con, here's my thoughts:

Chat class looks to be a good candidate to hold the storeKey to identify a MessageBag. Here's why:

Conceptually a Chat is a couple, consisting of an Agent and a MessageStore. The Agent can generate messages, the MessageStore can store User, Assitant and System messages.

Setting up the Chat would consist of passing an Agent, and a MessageStore, but to be able to retrieve an existing MessageBag, the MessageStore needs the identifier of this specific MessageBag (exactly like an Entity in a database, using Doctrine ORM).

Well, here's my suggestion:

Chat class could be constructed with a storeKey, or initiated post-construction with a storeKey. This way, a default Chat service can be configured in the Bundle, or the storeKey can be set after Chat construction, leaving the opportunity to developers to load the key from anywhere they want.

final readonly class Chat implements ChatInterface
{
    public function __construct(
        private AgentInterface $agent,
        private MessageStoreInterface $store,
        private string $storeKey,
    ) {
    }

    public function initiate(MessageBagInterface $messages, ?string $storeKey = null): void
    {
        // Using symfony/uid or any other way to generate a unique identifier.
        $this->storeKey ??= $storeKey ?? (string) Uuid::v7(); 

        $this->store->clear();
        $this->store->save($messages, $this->storeKey);
    }

    // Should be added in the ChatInterface
    public function getStoreKey(): string
    {
        return $this->storeKey;
    }

    public function submit(UserMessage $message): AssistantMessage
    {
        $messages = $this->store->load($this->storeKey);

        $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->storeKey);

        return $assistantMessage;
    }
}

In MessageStoreInterface, we add a new setKey method. But it's also possible to pass a key manually on each R/W method to load a specific MessageBag on-demand.

interface MessageStoreInterface
{
    public function save(MessageBagInterface $messages, ?string $key = null): void;

    public function load(?string $key = null): MessageBagInterface;

    public function clear(?string $key = null): void;

    public function setKey(string $key): void;
}

This way, the responsibility of the storeKey storage is up to developers (in env var, vault, database, low-orbit satellite, ...). Then Chat will can hold a key to manipulate the MessageStore and be able to swap to another key on developer needs to load another MessageBag.

Is this design looks ok for you? @Guikingone @chr-hertel @OskarStark

EDIT:

If developers want to use directly Agent and MessageStore, they have the same liberty to manipulate the store key, directly on their MessageStore (Cache, Session, InMemory, or any future one). Maybe we should move the key generation in an AbstractMessageStore class, no?

@chr-hertel
Copy link
Member

Alright, maybe it's easier to find a common ground here when we talk about user land code, for example a controller using the chat. And let's imagine we're not using the SessionStore, but one that needs the context from outside, like we discussed.

We have a route that expects a submitted message of a user and returns the agents response - chat & message store making sure it is persistent. We expect the chat already to be initialized before (or maybe that's something on demand based on config 🤔)

Version A: StoreId + Chat Injected

class MyChatController
{
    public function __construct(
        private ChatInterface $chat,
    ) {
    }

    #[Route(...)]
    public function __invoke(Request $request): JsonResponse
    {
        $message = $request->get('message');
        $storeId = $request->getSession()->get('store_id');

        // making sure it is the right storage used
        $chat->setStoreId($someId);

        $response = $chat->submit($message);

        return new JsonResponse($response);
    }
}

B: StoreId + Chat Constructed

class MyChatController
{
    public function __construct(
        private AgentInterface $agent,
        private MessageStoreInterface $store,
    ) {
    }

    #[Route(...)]
    public function __invoke(Request $request): JsonResponse
    {
        $message = $request->get('message');
        $storeId = $request->getSession()->get('store_id');

        $chat = new Chat($this->agent, $this->store, $storeId);

        $response = $chat->submit($message);

        return new JsonResponse($response);
    }
}

C: MessageBagId in submit

class MyChatController
{
    public function __construct(
        private ChatInterface $chat,
    ) {
    }

    #[Route(...)]
    public function __invoke(Request $request): JsonResponse
    {
        $message = $request->get('message');
        $messageBagId = $request->getSession()->get('message_bag_id');

        $response = $chat->submit($messageBagId, $message);

        return new JsonResponse($response);
    }
}

D: MessageStore Magic (tbd)

class MyChatController
{
    public function __construct(
        private ChatInterface $chat,
    ) {
    }

    #[Route(...)]
    public function __invoke(Request $request): JsonResponse
    {
        $message = $request->get('message');

        $response = $chat->submit($message);

        return new JsonResponse($response);
    }
}

So with A) we create a stateful service that users would need to handle with care, with B) we introduce basically another state object on top of the MessageBag with service dependencies, and with C) we make the contract less sexy.

So we still have D) find a way that message store implementations would handle that mess for us 🤔

(Please, if you have better examples to optimize for - bring 'em on! 🙏)

@Guikingone
Copy link
Contributor Author

Regarding the A version, actually, the setId on Chat can be moved to its constructor, this way, we inject the class then call submit without taking care of setting an id at runtime, I'm in favor of not allowing to change the id without resetting the Chat.

IMHO, a Chat should not be used (in framework context) without defining it in the configuration, after all, you can use agents without defining them in the configuration but is it the "good way to do it"? I'm not sure, I can create HttpClients outside of the configuration but I completely bypass the scoped ones, the env variables support, injection and more, so I'm bypassing "a lot of helpers".

That's why the approach explained by @welcoMattic seems "a good start" on my side, you're defining the key while configuring the chat then "if and only if" you need to override it, you can do it thanks to initiate, there's no "magic id setup", no "magic configuration" of some sort or else, you decide to trigger a chat, you define it in the configuration, you receive it in your classes then you submit messages.

If you're using a Chat outside of the framework context, same process, you're creating one, you define the id, the store and the agent then you can submit messages, the Agent doesn't know which messages are used when submitting new ones and the Chat still the main entrypoint.

IMHO, C) approach is weird, adding the "id" of messages while submitting them looks strange, you need to define it at runtime and/or wait for it (Request, etc) before submitting the message.

I would love to see the D) approach but I'm completely honest, I don't know how to do it without creating a stateful object either via Chat or MessageStore, we must keep an identifier between conversations and without storing it in Chat or defining it at MessageStore level, I don't know how to do it, for me, MessageBag is not designed for this and even if we store it at the bag level, how do we tell Chat that it must retrieve this bag and not another one?

My approach would be either to side with @welcoMattic (without the getter from Chat, I don't see the benefit here as we already know which key is used when we configure it) or to define the key at MessageStore level, this last one will force to create a new store for each Chat but doesn't seem a big issue while in the framework context.

@welcoMattic
Copy link
Member

To be explicit, here is my use case (simplified):

<?php

namespace App\MessageHandler;

#[AsMessageHandler]
class ProcessAiResponseMessageHandler
{
    public function __construct(
        private readonly AgentInterface $agent,
        private readonly MessageStoreInterface $messageStore,
        private readonly ConversationRepository $conversationRepository,
    ) {
    }

    public function __invoke(ProcessAiResponseMessage $message): void
    {
        $conversation = $this->conversationRepository->find($message->conversationId);

        $bagId = md5('conversation/' . $conversation->getId());

        $chat = new Chat($this->agent, $this->messageStore, $bagId);

        $assistantMessage = $chat->submit(Message::ofUser($message->userMessage));

        // continue processing
    }
}

$bagId is here to identify a specific MessageBagInterface object.

ChatInterface.php

interface ChatInterface
{
    public function initiate(MessageBagInterface $messages, ?string $bagId = null): void;

    public function submit(UserMessage $message): AssistantMessage;

    public function setBagId(string $bagId): void;
}

MessageStoreInterface.php

interface MessageStoreInterface
{
    public function save(MessageBagInterface $messages, ?string $bagId = null): void;

    public function load(?string $bagId = null): MessageBagInterface;

    public function clear(?string $bagId = null): void;

    public function setBagId(string $bagId): void;
}

It's similar to what suggested by @Guikingone (including the rewrite of InMemoryStore to be able to store multiple bags).

This way, a user can configure stores with a default bagId, or instantiate a store manually and passing a bagId to constructor, or finally passing a specific bagId on the fly for each method (load, save, clear).

I've tricked my vendors to test it in a real case, and it works like a charm. Let me know if you want me to send more code here (especially implementations).

@Guikingone Guikingone force-pushed the agent/session branch 3 times, most recently from 9e8b6b0 to eb14cc7 Compare September 5, 2025 15:33
@chr-hertel
Copy link
Member

please give me a chance to have another look before merging - was quite busy with that mcp thingy, but this is quite a change, and i'd like to give it more thought.

Copy link
Member

@chr-hertel chr-hertel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wow, that profiler thing is new :D 👍

a few things:

  • i think it's a good to rework the wikipedia example, but for the youtube and audio ones, it's a step into the wrong direction in my opinion
    • twig components are like controllers to me, and should only bring infrastructure and logic together - in the case of audio and youtube they now do more - due to the fact that there is no way to hook into the initialize of the chat - might be a missing extension point - or a InputProcessor :)
    • just look at the constructors of those classes - that's too much
    • so we either work on that as well, or revert those changes for now, and do it in another
  • the chat should not leak the message store - if i need to inject both services, the integration is not complete => so the chat needs a clear or reset
  • state handling is odd
    • still stateful services
    • nullable ids in the interfaces also break my heart here
    • mixing ID and session keys
    • ... but we can work on better example than session and cache to get it right => doctrine maybe?

@Guikingone
Copy link
Contributor Author

Guikingone commented Sep 8, 2025

Hi @chr-hertel @OskarStark 👋🏻

Regarding your thoughts @chr-hertel, I might have a solution for the id issue, it's not "perfect" but it could help keeping the Chat "as a bridge" between message store and messages rather than storing an identifier "in between".

Don't know if you've planned to work on this during the hackathon, if so, could you give me a few days to test/push my idea? It might not be THE solution but we never know, could be one 😅

@OskarStark
Copy link
Contributor

I think we have enough other tasks to tackle, so take your time on this one I would say

@OskarStark
Copy link
Contributor

I would create a new MessageStore component for this

@Guikingone
Copy link
Contributor Author

Just to store chat messages? 🤔

The Store component cannot be rebranded to handle it?

@OskarStark
Copy link
Contributor

I am thinking about it and in terms of packages it may make sense to split them, but on the other hand...

@Guikingone
Copy link
Contributor Author

@OskarStark I was thinking about the split of the message store / chat, what about a chat component? This way, we could handle the storage / chat part of the project outside of the agent (and just link it as a deps)?

@Guikingone
Copy link
Contributor Author

I just pushed a new version (on a separate commit to help reading it), this one remove the id from the chat and moves it into the store only (because we can't do it without it anyway), it also introduce the chat "sub-component" and a trait to help handling the identifier.

You'll also find a fork method similar to previous one but with a single responsibility (and without optional argument) that can be used to "change" the id without impacting the current chat.

@welcoMattic It's close to the previous API, just "different" on its usage 😅

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's go with a chat folder in the examples 👍

Comment on lines +88 to +108

private function doStart(string $videoId): void
{
$transcript = $this->transcriptFetcher->fetchTranscript($videoId);
$system = <<<PROMPT
You are an helpful assistant that answers questions about a YouTube video based on a transcript.
If you can't answer a question, say so.
Video ID: {$videoId}
Transcript:
{$transcript}
PROMPT;

$messages = new MessageBag(
Message::forSystem($system),
Message::ofAssistant('What do you want to know about that video?'),
);

$this->reset();
$this->chat->initiate($messages);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not a big fan of putting more logic into the TwigComponent - especially additional service dependencies.

can we have some kind of initialize hook/method/event in the chat instead?

Comment on lines +35 to +38
#[Autowire(service: 'ai.chat.youtube')]
private readonly ChatInterface $chat,
#[Autowire(service: 'ai.message_store.cache.youtube')]
private readonly MessageStoreInterface $messageStore,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would still be great to hide the store as internal of the chat - instead having to deal with both services in user land.

Comment on lines +71 to +86
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'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we make it more concise here?

Suggested change
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'
chat:
audio:
agent: 'audio'
type: 'cache'
service: 'cache.app'
wikipedia:
agent: 'wikipedia'
youtube:
agent: 'youtube'

@carsonbot carsonbot changed the title [AI Bundle][Agent][Demo][Examples][Chat] Add message store session support [AI Bundle][Agent][Demo][Run examples][Chat] Add message store session support Sep 26, 2025
OskarStark added a commit that referenced this pull request Oct 3, 2025
This PR was merged into the main branch.

Discussion
----------

[Chat] Introduce a new component

| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| Docs?         | yes
| Issues        | Related to #254
| License       | MIT

Hi 👋🏻

Ok, time to tackle the big work on the #254 issue, the current structure doesn't allows to introduce new stores neither handling the storage of messages, mainly due to the fact that we can't intervene in the agent component without breaking the SRP of it.

This PR aims to split the `Chat` into a new component and ease the work on storing messages (with a new component, we can easily add storages here rather than in the `agent` component), plus, it helps splitting the responsibilities inside the initiative as we should be allowed to start new agents without relying on any chats.

This PR is a draft, feel free to open debates about it 😄

Commits
-------

3f1dd48 refactor(core): chat sub-component started
@OskarStark OskarStark changed the title [AI Bundle][Agent][Demo][Run examples][Chat] Add message store session support [AI Bundle][Agent][Demo][Chat] Add message store session support Oct 3, 2025
@OskarStark
Copy link
Contributor

Rebase unlocked, as #675 is now merged 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Agent Issues & PRs about the AI Agent component AI Bundle Issues & PRs about the AI integration bundle Demo Issues & PRs about the demo application Run examples Issues & PRs about the example scripts Status: Needs Work
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants