How Chat History Persistence Works in Derisk Agent Conversations

Derisk-Serve persists every agent conversation turn to a relational database using a three-layer architecture—conversation objects, storage adapters, and SQLAlchemy-backed services—enabling full chat history retrieval via REST API.

The derisk-ai/openderisk framework implements transactional chat history persistence to ensure LLM-driven agent conversations survive process restarts and remain queryable for auditing or UI replay. This implementation relies on explicit storage adapters that map in-memory conversation objects to database rows, separating concerns between business logic and data access.

The Three-Layer Persistence Architecture

The persistence pipeline is structured into distinct layers to decouple agent logic from storage implementation:

  • Conversation objects: In-memory representations (StorageConversation) that aggregate messages and metadata.
  • Storage adapters: Translation layers (DBStorageConversationItemAdapter, DBMessageStorageItemAdapter) that convert objects to SQLAlchemy entities.
  • Serve component: The Serve class that instantiates concrete SQLAlchemyStorage backends and exposes the history API.

This design allows agents to remain storage-agnostic while guaranteeing atomic persistence of conversation state.

In-Memory Conversation Objects

StorageConversation Interface

The core in-memory representation is the StorageConversation class defined in derisk/core/interface/message.py. This object maintains a list of MessageStorageItem instances, a unique conv_uid, and references to the storage backends. Key methods include add_user_message, add_view_message, and save_to_storage.

When an agent initiates a turn, the _build_conversation function in derisk-serve/src/derisk_serve/agent/agents/chat/agent_chat.py constructs a StorageConversation and injects the conversation-level and message-level storage adapters:


# derisk-serve/src/derisk_serve/agent/agents/chat/agent_chat.py

async def _build_conversation(...):
    return await StorageConversation(
        conv_uid=conv_id,
        chat_mode="chat_agent",
        user_name=user_name,
        sys_code=sys_code,
        model_name=model_name,
        summary=summary,
        param_type="derisks",
        param_value=select_param,
        app_code=app_code,
        conv_storage=conv_serve.conv_storage,
        message_storage=conv_serve.message_storage,
        async_load=True,
    ).async_load()

The async_load=True parameter triggers immediate re-hydration of existing messages from the database when reconnecting to an ongoing conversation.

Database Adapters and Entity Mapping

Conversation-Level Persistence

The DBStorageConversationItemAdapter in derisk/storage/chat_history/storage_adapter.py translates StorageConversation objects into ChatHistoryEntity rows for the chat_history table. The adapter's to_storage_format method serializes message references and generates a summary field:


# derisk/storage/chat_history/storage_adapter.py

def to_storage_format(self, item: StorageConversation) -> ChatHistoryEntity:
    message_ids = ",".join(item.message_ids)
    messages = None
    if not item.save_message_independent and item.messages:
        message_dict_list = [_conversation_to_dict(item)]
        messages = json.dumps(message_dict_list, ensure_ascii=False)
    summary = item.summary or item.get_latest_user_message().last_text[:250]
    return ChatHistoryEntity(
        conv_uid=item.conv_uid,
        chat_mode=item.chat_mode,
        summary=summary,
        user_name=item.user_name,
        messages=messages,
        message_ids=message_ids,
        sys_code=item.sys_code,
        app_code=item.app_code,
    )

The adapter concatenates message IDs into a comma-separated string and stores legacy full-conversation JSON only when save_message_independent is disabled. The summary field auto-populates from the first 250 characters of the latest user message.

Message-Level Persistence

Individual messages are persisted separately via DBMessageStorageItemAdapter, which maps MessageStorageItem objects to ChatHistoryMessageEntity rows in the chat_history_message table. This adapter extracts the round_index from message metadata and stores the full detail as JSON:


# derisk/storage/chat_history/storage_adapter.py

def to_storage_format(self, item: MessageStorageItem) -> ChatHistoryMessageEntity:
    round_index = item.message_detail.get("round_index", 0)
    message_detail = json.dumps(item.message_detail, ensure_ascii=False)
    return ChatHistoryMessageEntity(
        conv_uid=item.conv_uid,
        index=item.index,
        round_index=round_index,
        message_detail=message_detail,
    )

Both adapters implement from_storage_format to support bidirectional translation when loading historical data.

The Save and Load Lifecycle

Persisting Conversation Rounds

Persistence is triggered explicitly at the end of each LLM turn. The AgentChat class calls save_conversation, which invokes end_current_round on the current message. This method delegates to StorageConversation.save_to_storage:


# derisk/core/interface/message.py

def end_current_round(self) -> None:
    """End the current round of conversation."""
    self.save_to_storage()

def save_to_storage(self) -> None:
    # Save messages first

    message_list = self._get_message_items()
    self._message_ids = [...]
    # Persist the conversation record

    self.conv_storage.save(self, self.message_storage)

The save_to_storage method writes message rows first, then persists the conversation metadata with references to those messages. This two-phase commit ensures referential integrity between the chat_history and chat_history_message tables.

Loading Existing Conversations

When resuming a conversation, the Service.create_storage_conv method instantiates StorageConversation with load_message=True. The constructor immediately calls async_load (or load_from_storage for synchronous contexts), which queries the database via the storage adapters and reconstructs the in-memory message list:


# Conceptual flow based on derisk-serve/src/derisk_serve/conversation/service/service.py

conv = StorageConversation(
    conv_uid=existing_id,
    load_message=True,  # Triggers database query

    conv_storage=conv_storage,
    message_storage=message_storage,
)

Serve Component and API Exposure

The Serve class in derisk-serve/src/derisk_serve/conversation/serve.py initializes the storage layer during before_start. It creates SQLAlchemyStorage instances for both conversation and message entities, binding them to the database manager:


# derisk-serve/src/derisk_serve/conversation/serve.py

self._conv_storage = SQLAlchemyStorage(
    self._db_manager,
    ChatHistoryEntity,
    DBStorageConversationItemAdapter(),
    JsonSerializer(),
)
self._message_storage = SQLAlchemyStorage(
    self._db_manager,
    ChatHistoryMessageEntity,
    DBMessageStorageItemAdapter(),
    JsonSerializer(),
)

The service registers the REST endpoint GET /messages/history (defined in derisk-serve/src/derisk_serve/conversation/api/endpoints.py), which delegates to Service.get_history_messages. This method reconstructs the StorageConversation from the database and returns a list of MessageVo objects for UI consumption.

Implementation Examples

Initializing the Storage Service

Usually performed by the application framework during startup:

from derisk_serve.conversation.serve import Serve
from derisk.component import SystemApp

system_app = SystemApp()
serve = Serve(system_app)
serve.init_app(system_app)  # Creates tables and mounts routers

Creating and Saving Agent Conversations

Inside an agent implementation:

from derisk_serve.agent.agents.chat.agent_chat import _build_conversation

conv = await _build_conversation(
    conv_id="c12345",
    select_param={},
    model_name="gpt-4",
    summary="",
    app_code="my_app",
    conv_serve=ConversationServe.get_instance(system_app),
    user_name="alice",
    sys_code="derisk",
)

# Append user input and persist

conv.add_user_message("What is my portfolio risk?")
conv.end_current_round()  # Triggers save_to_storage → Database rows

Retrieving Chat History

Via the REST API:

curl -X GET "http://localhost:8000/api/v1/serve/derisk/messages/history?conv_uid=c12345"

Or programmatically through the service layer:

from derisk_serve.conversation.service.service import Service

service = Service(system_app, config)
history = service.get_history_messages({"conv_uid": "c12345"})
for msg in history:
    print(msg.role, msg.context)

Summary

  • StorageConversation objects in derisk/core/interface/message.py provide the in-memory interface for agents, abstracting database details.
  • Adapter pattern: DBStorageConversationItemAdapter and DBMessageStorageItemAdapter in derisk/storage/chat_history/storage_adapter.py handle bidirectional translation between objects and SQLAlchemy entities.
  • Two-phase persistence: Messages are saved individually to chat_history_message before the parent conversation record updates in chat_history, maintaining referential integrity.
  • Explicit lifecycle: Persistence occurs only when end_current_round is called, allowing agents to control exactly when state is committed.
  • Serve component instantiates SQLAlchemyStorage backends and exposes the history via the /messages/history REST endpoint.

Frequently Asked Questions

How is conversation data structured in the database?

Conversation metadata resides in the chat_history table via ChatHistoryEntity, storing fields like conv_uid, chat_mode, and a comma-separated message_ids string. Individual messages are stored in the chat_history_message table via ChatHistoryMessageEntity, containing round_index, index, and JSON message_detail. This separation allows efficient querying of conversation lists without loading full message content.

What triggers the persistence of a conversation round?

The AgentChat.save_conversation method calls current_message.end_current_round(), which internally invokes StorageConversation.save_to_storage(). This design ensures persistence happens only after the LLM completes generation and the agent finalizes the turn, preventing storage of incomplete or failed interactions.

Can conversations be retrieved asynchronously?

Yes. The StorageConversation supports async operations via async_load(), which is automatically invoked when async_load=True is passed to the constructor. The Serve component uses SQLAlchemyStorage with async-capable database managers, allowing non-blocking history retrieval in high-throughput scenarios.

Where is the chat history API endpoint defined?

The REST endpoint GET /messages/history is registered in derisk-serve/src/derisk_serve/conversation/api/endpoints.py. It delegates to Service.get_history_messages in derisk-serve/src/derisk_serve/conversation/service/service.py, which uses the storage adapters to reconstruct StorageConversation objects from the database and return MessageVo responses.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →