--- status: proposed contact: crickman date: 2024-06-24 deciders: bentho, matthewbolanos --- # `AgentChat` Serialization / Deserialization ## Context and Problem Statement Users of the _Agent Framework_ are unable to store and later retrieve conversation state when using an `AgentChat` to coordinate `Agent` interactions. This limits the ability for an agent conversation to single use as it must be maintained with memory of the process that initiated the conversation. Formalizing a mechanism that supports serialization and deserialization of any `AgentChat` class provides an avenue to capture and restore state across multiple sessions as well as compute boundaries. #### Goals - **Capture & Restore Primary Chat History**: The primary `AgentChat` history must be captured and restored for full fidelity. - **Capture & Restore Channel State**: In addition to the primary chat history, the state for each `AgentChannel` within the `AgentChat` must be captured and restored. - **Capture Agent Metadata**: Capturing the agent Identifier, Name, and Type upon serialization provides a guidance on how to restore the the `AgentChat` during deserialization. #### Non-Goals - **Manage agent definition:** An `Agent` definition shall not be captured as part of the conversation state. `Agent` instances will not be produced when deserializing the state of an `AgentChat` class. - **Manage secrets or api-keys:** Secrets / api-keys are required when producing an `Agent` instance. Managing this type of sensitive data is out-of-scope due to security considerations. ## Issues - Serialized `ChatHistory` must be equivalent across platforms / languages for interoperability ## Cases When restoring an `AgentChat`, the application must also re-create the `Agent` instances participating in the chat (outside of the control of the deserialization process). This creates the opportunity for the following cases: #### 1. **Equivalent:** All of the original agent types (channels) available in the restored chat. This shall result in a full-fidelity restoration of of the original chat. |Source Chat|Target Chat| |---|---| |`ChatCompletionAgent`|`ChatCompletionAgent`| |`OpenAIAssistantAgent`|`OpenAIAssistantAgent`| |`ChatCompletionAgent` & `OpenAIAssistantAgent`|`ChatCompletionAgent` & `OpenAIAssistantAgent`| #### 2. **Enhanced:** Additional original agent types (channels) available in the restored chat. This shall also result in a full-fidelity restoration of of the original chat. Any new agent type (channel) will synchronize to the chat once restored (identical to adding a new agent type to a chat that is progress). |Source Chat|Target Chat| |---|---| |`ChatCompletionAgent`|`ChatCompletionAgent` & `OpenAIAssistantAgent`| |`OpenAIAssistantAgent`|`ChatCompletionAgent` & `OpenAIAssistantAgent`| #### 3. **Reduced:** A subset of original agent types (channels) available in the restored chat. This shall also result in a full-fidelity restoration of of the original chat to the available channels. Introduction of a missing agent type (channel) post restoration will synchronize the channel to the current chat (identical to adding a new agent type to a chat that is progress). |Source Chat|Target Chat| |---|---| |`ChatCompletionAgent` & `OpenAIAssistantAgent`|`ChatCompletionAgent`| |`ChatCompletionAgent` & `OpenAIAssistantAgent`|`OpenAIAssistantAgent`| #### 4. **Empty:** No agents available in the restored chat. This shall result in an immediate exception (fail-fast) in order to strongly indicate that the chat has not been restored. The chat may have agents added in order to attempt a successful restoration, or utilized on its own. That is, the `AgentChat` instance isn't invalidated. #### 5. **Invalid:** Chat has already developed history or channels state. This shall result in an immediate exception (fail-fast) in order to strongly indicate that the chat has not been restored. The chat may continue to be utilized as the `AgentChat` instance isn't invalidated. #### Notes: > Once restored, additional `Agent` instances may join the `AgentChat`, no different from any `AgentChat` instance. ## Analysis #### Relationships: The relationships between any `AgentChat`, the `Agent` instances participating in the conversation, and the associated `AgentChannel` conduits are illustrated in the following diagram:

While an `AgentChat` manages a primary `ChatHistory`, each `AgentChannel` manages how that history is adapted to the specific `Agent` modality. For instance, an `AgentChannel` for an `Agent` based on the Open AI Assistant API tracks the associated _thread-id_. Whereas a `ChatCompletionAgent` manages an adapted `ChatHistory` instance of its own. This implies that logically the `AgentChat` state must retain the primary `ChatHistory` in addition to the appropriate state for each `AgentChannel`: #### Logical State: These relationships translate into the following logical state definition:

#### Serialized State: ```javascript { // Serialized ChatHistory "history": [ { "role": "user", "items": [ /* ... */ ] }, { "role": "assistant", "name": "John", "items": [ /* ... */ ] }, // ... ], // Serialized Participants "participants": [ { "id": "01b6a120-7fef-45e2-aafb-81cf4a90d931", "name": "John", "type": "ChatCompletionAgent" }, // ... ], // Serialized AgentChannel state "channels": [ { "channelkey": "Vdx37EnWT9BS+kkCkEgFCg9uHvHNw1+hXMA4sgNMKs4=", "channelstate": "...", // Serialized state for an AgentChannel }, // ... ] } ``` ## Options #### 1. JSON Serializer: A dominant serialization pattern is to use the dotnet `JsonSerializer`. This is the approach relied upon by the _Semantic Kernel_ content types. **Serialize Example:** (_dotnet_) ```c# // Create the agents ChatCompletionAgent agent1 = ...; OpenAIAssistantAgent agent2 = ...; // Create the agent-chat AgentGroupChat chat = new(agent1, agent2); // Serialize the chat object to JSON string chatState = JsonSerializer.Serialize(chat); ``` (_python_) ```python # Create the agents agent1 = ChatCompletionAgent(...) agent2 = OpenAIAssistantAgent(...) # Create the agent-chat chat = AgentGroupChat(agent1, agent2) # Serialize the chat to JSON chat_state = chat.model_dump() ``` **Deserialize Example:** (_dotnet_) ```c# // Deserialize JSON AgentGroupChat chat = JsonSerializer.Deserialize(chatState); ``` (_python_) ```python # Deserialize JSON def agent_group_chat_decoder(obj) -> AgentGroupChat: pass chat = json.loads(chat_state, object_hook=agent_group_chat_decoder) ``` **Pro:** - Doesn't require knowledge of a serialization pattern specific to the _Agent Framework_. **Con:** - Both `AgentChat` nor `AgentChannel` are designed as a service classes, not _data transfer objects_ (DTO's). Implies disruptive refactoring. (Think: complete re-write) - Requires caller to address complexity to support serialization of unknown `AgentChannel` and `AgentChat` subclasses. - Limits ability to post process when restoring chat (e.g. channel synchronization). - Absence of `Agent` instances in deserialization interferes with ability to restore any `AgentChannel`. #### 2. `AgentChat` Serializer: Introducing a serializer with specific knowledge of `AgentChat` contracts enables the ability to streamline serialization and deserialization. (_dotnet_) ```c# class AgentChatSerializer { // Captures chat state to the provided stream static async Task SerializeAsync(AgentChat chat, Stream stream) // Reads chat state from the provided stream and returns serializer static async Task DeserializeAsync(AgentChat chat, Stream stream) // Provides list of participants IReadOnlyList GetParticipants(); // Restores the chat state Task RestoreAsync(AgentChat chat); } ``` (_python_) ```python class AgentChatSerializer: # Captures chat state to the provided stream @staticmethod async def serialize(chat: AgentChat, stream); pass # Reads chat state from the provided stream and returns serializer @staticmethod async def deserialize(chat: AgentChat, stream) -> AgentChatSerializer: pass # Provides list of participants def get_participants(self) -> list[ChatParticipant]: pass # Restores the chat state async def restore(self, chat: AgentChat): pass ``` **Pro:** - Able to clearly define the chat-state, separate from the chat _service_ requirements. - Support any `AgentChat` and `AgentChannel` subclass. - Ability to support post processing when restoring chat (e.g. channel synchronization). - Allows any `AgentChat` to be properly initialized prior to deserialization. - Allows for inspection of `ChatParticipant` metadata. **Con:** - Require knowledge of a serialization pattern specific to the _Agent Framework_. **Serialize Example:** (_dotnet_) ```c# // Create agents ChatCompletionAgent agent1 = ...; OpenAIAssistantAgent agent2 = ...; // Create agent-chat AgentGroupChat chat = new(agent1, agent2); // Initiate conversation await chat.InvokeAsync(); // Initialize the serialization stream async using Stream stream = ...; // Capture agent-chat await AgentChatSerializer.SerializeAsync(chat, stream); ``` (_python_) ```python # Create agents agent1 = ChatCompletionAgent(...) agent2 = OpenAIAssistantAgent(...) # Create agent-chat chat = AgentGroupChat(agent1, agent2) # Initiate conversation await chat.invoke() # Initialize the serialization stream async with ... as stream: # Capture agent-chat await AgentChatSerializer.serialize(chat, stream) ``` **Deserialize Example:** (_dotnet_) ```c# // Create agents ChatCompletionAgent agent1 = ...; OpenAIAssistantAgent agent2 = ...; Dictionary agents = new() { { agent1.Id, agent1 }, { agent2.Id, agent2 }, } // Initialize the deserialization stream async using Stream stream = ...; AgentChatSerializer serializer = AgentChatSerializer.Deserialize(stream); // Create agent-chat AgentGroupChat chat = new(); // Restore agents foreach (ChatParticipant participant in serializer.GetParticipants()) { chat.AddAgent(agents[participant.Id]); } // Restore chat serializer.Deserialize(chat); // Continue chat await chat.InvokeAsync(); ``` (_python_) ```python # Create agents agent1 = ChatCompletionAgent(...) agent2 = OpenAIAssistantAgent(...) agents = { agent1.id: agent1, agent2.id: agent2, } # Initialize the serialization stream async with ... as stream: serializer = await AgentChatSerializer.serialize(stream) # Create agent-chat chat = AgentGroupChat(agent1, agent2) # Restore agents for participant in serializer.get_participants(): chat.add_agent(agents[participant.id]) # Restore agent-chat await serializer.deserialize(chat) # Continue chat await chat.invoke(); ``` #### 3. Encoded State This option is identical to the second option; however, each discrete state is base64 encoded to discourage modification / manipulation of the captured state. **Pro:** - Discourages ability to inspect and modify. **Con:** - Obscures ability to inspect. - Still able to decode to inspect and modify. **Serialized State:** ```javascript { "history": "VGhpcyBpcyB0aGUgcHJpbWFyeSBjaGF0IGhpc3Rvcnkg...", "participants": [ { "aId37EnWT9BS+kkCkEgFCg9uHvHNw1+hXMA4sgNMKs4...", // ... }, ], "channels": [ { "channelkey": "Vdx37EnWT9BS+kkCkEgFCg9uHvHNw1+hXMA4sgNMKs4=", "channelstate": "VGhpcyBpcyBhZ2VudCBjaGFubmVsIHN0YXRlIGV4YW1wbG..." }, // ... ] } ``` ## Outcome TBD