Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 75 additions & 8 deletions python/packages/core/agent_framework/_threads.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,25 @@ def __init__(
if service_thread_id is not None and chat_message_store_state is not None:
raise AgentThreadException("A thread cannot have both a service_thread_id and a chat_message_store.")
self.service_thread_id = service_thread_id
self.chat_message_store_state: ChatMessageStoreState | None = None
self.chat_message_store_state: MutableMapping[str, Any] | ChatMessageStoreState | None = None
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

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

The type annotation change from ChatMessageStoreState | None to MutableMapping[str, Any] | ChatMessageStoreState | None is correct and necessary to support custom stores. However, this is a breaking change to the public API since code that accesses chat_message_store_state.messages directly may now fail if it's a dict instead of a ChatMessageStoreState object.

Consider documenting this breaking change more prominently in the PR description and potentially adding a BREAKING prefix to the PR title as mentioned in the contribution checklist.

Copilot uses AI. Check for mistakes.
if chat_message_store_state is not None:
if isinstance(chat_message_store_state, dict):
self.chat_message_store_state = ChatMessageStoreState.from_dict(chat_message_store_state)
# Determine if this is a custom store that needs to preserve extra fields
# Standard ChatMessageStoreState has 'type' (from SerializationMixin) and 'messages'
# Create a temporary instance to get the expected fields dynamically
temp_state = ChatMessageStoreState()
standard_state_dict = temp_state.to_dict()
standard_fields = set(standard_state_dict.keys())
Comment on lines +176 to +181
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

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

Creating a temporary ChatMessageStoreState instance with no arguments may not be reliable for determining the standard fields. The 'type' field is dynamically added by SerializationMixin.to_dict() and will vary depending on whether the instance has custom type attributes. This could lead to incorrect detection of custom stores.

Instead, you should explicitly check for the known standard fields: {'type', 'messages'}. This is more robust and doesn't depend on runtime object instantiation.

Suggested change
# Determine if this is a custom store that needs to preserve extra fields
# Standard ChatMessageStoreState has 'type' (from SerializationMixin) and 'messages'
# Create a temporary instance to get the expected fields dynamically
temp_state = ChatMessageStoreState()
standard_state_dict = temp_state.to_dict()
standard_fields = set(standard_state_dict.keys())
# Determine if this is a custom store that needs to preserve extra fields.
# Standard ChatMessageStoreState has 'type' (from SerializationMixin) and 'messages'.
# Explicitly use the known standard fields instead of inferring them at runtime.
standard_fields = {"type", "messages"}

Copilot uses AI. Check for mistakes.
# Check if input has fields beyond what standard ChatMessageStoreState would have
extra_fields = set(chat_message_store_state.keys()) - standard_fields
if extra_fields:
# Custom store with additional fields (e.g., redis_url, thread_id, key_prefix)
# Preserve as dict to retain all custom configuration
self.chat_message_store_state = chat_message_store_state
else:
# Standard ChatMessageStoreState - convert for backward compatibility
# This handles both {"messages": []} and {"type": "...", "messages": [...]}
Comment on lines +182 to +190
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

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

The detection logic for custom stores may incorrectly classify stores that only differ in the 'type' field value as standard stores. For example, if a custom store serializes as {"type": "custom_store_state", "messages": []}, it will be treated as a standard store because it has no extra fields beyond 'type' and 'messages', and will be converted to ChatMessageStoreState with type "chat_message_store_state", losing the original custom type information.

To fix this, also check if the 'type' field value (when present) differs from 'chat_message_store_state'. If it does, treat it as a custom store and preserve as dict. For example:

if extra_fields or (
    "type" in chat_message_store_state
    and chat_message_store_state["type"] != "chat_message_store_state"
):
    # Custom store with additional fields or different type
    self.chat_message_store_state = chat_message_store_state
Suggested change
# Check if input has fields beyond what standard ChatMessageStoreState would have
extra_fields = set(chat_message_store_state.keys()) - standard_fields
if extra_fields:
# Custom store with additional fields (e.g., redis_url, thread_id, key_prefix)
# Preserve as dict to retain all custom configuration
self.chat_message_store_state = chat_message_store_state
else:
# Standard ChatMessageStoreState - convert for backward compatibility
# This handles both {"messages": []} and {"type": "...", "messages": [...]}
standard_type = standard_state_dict.get("type")
# Check if input has fields beyond what standard ChatMessageStoreState would have
extra_fields = set(chat_message_store_state.keys()) - standard_fields
if extra_fields or (
"type" in chat_message_store_state
and standard_type is not None
and chat_message_store_state["type"] != standard_type
):
# Custom store with additional fields or different type (e.g., redis_url, thread_id, key_prefix)
# Preserve as dict to retain all custom configuration
self.chat_message_store_state = chat_message_store_state
else:
# Standard ChatMessageStoreState - convert for backward compatibility
# This handles both {"messages": []} and {"type": standard_type, "messages": [...]}

Copilot uses AI. Check for mistakes.
self.chat_message_store_state = ChatMessageStoreState.from_dict(chat_message_store_state)
elif isinstance(chat_message_store_state, ChatMessageStoreState):
self.chat_message_store_state = chat_message_store_state
else:
Expand Down Expand Up @@ -464,13 +479,38 @@ async def deserialize(
return cls()

if message_store is not None:
# Handle custom message stores (e.g., Redis) that need full state deserialization
try:
await message_store.add_messages(state.chat_message_store_state.messages, **kwargs)
if isinstance(state.chat_message_store_state, dict):
# Custom store: use update_from_state method
await message_store.update_from_state(state.chat_message_store_state, **kwargs)
elif isinstance(state.chat_message_store_state, ChatMessageStoreState):
# Legacy ChatMessageStoreState: extract messages
await message_store.add_messages(state.chat_message_store_state.messages, **kwargs)
except Exception as ex:
raise AgentThreadException("Failed to deserialize the provided message store.") from ex
return cls(message_store=message_store)

# No message_store provided, try to deserialize based on state type
try:
message_store = ChatMessageStore(messages=state.chat_message_store_state.messages, **kwargs)
if isinstance(state.chat_message_store_state, dict):
# Try to determine the store type from the serialized state
store_state_dict = state.chat_message_store_state
if "messages" in store_state_dict:
parsed_state = ChatMessageStoreState.from_dict(store_state_dict)
message_store = ChatMessageStore(messages=parsed_state.messages, **kwargs)
else:
raise AgentThreadException(
"Cannot deserialize custom message store without providing a message_store instance. "
"Please provide a message_store parameter to deserialize()."
)
Comment on lines +500 to +506
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

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

The error message says "Cannot deserialize custom message store without providing a message_store instance" but this check is triggered even for standard stores that have a 'messages' field. The condition should be checking if this is actually a custom store (no 'messages' field) before raising this error.

Consider restructuring the logic to: (1) check if 'messages' exists, if yes create standard ChatMessageStore, (2) if no 'messages' but has 'type' field matching known custom types, raise this error, (3) otherwise raise a generic "Invalid state" error.

Suggested change
parsed_state = ChatMessageStoreState.from_dict(store_state_dict)
message_store = ChatMessageStore(messages=parsed_state.messages, **kwargs)
else:
raise AgentThreadException(
"Cannot deserialize custom message store without providing a message_store instance. "
"Please provide a message_store parameter to deserialize()."
)
# Standard ChatMessageStore serialized state
parsed_state = ChatMessageStoreState.from_dict(store_state_dict)
message_store = ChatMessageStore(messages=parsed_state.messages, **kwargs)
elif "type" in store_state_dict:
# Looks like a custom store; require caller-provided message_store instance
raise AgentThreadException(
"Cannot deserialize custom message store without providing a message_store instance. "
"Please provide a message_store parameter to deserialize()."
)
else:
# Missing both 'messages' and 'type' fields; treat as invalid state
raise AgentThreadException("Invalid chat_message_store_state; missing 'messages' and 'type' fields.")

Copilot uses AI. Check for mistakes.
elif isinstance(state.chat_message_store_state, ChatMessageStoreState):
# Legacy ChatMessageStoreState object
message_store = ChatMessageStore(messages=state.chat_message_store_state.messages, **kwargs)
else:
raise AgentThreadException("Invalid chat_message_store_state type.")
except AgentThreadException:
raise
Comment on lines +512 to +513
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

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

This re-raises AgentThreadException but then has a catch-all for other exceptions. The problem is that if the code at line 500 (ChatMessageStoreState.from_dict) raises an AgentThreadException, it will be caught by line 512 and re-raised. However, if line 501 (ChatMessageStore construction) fails with an AgentThreadException, it will also be caught and re-raised. This double-catch pattern is confusing and may not preserve the original exception context properly.

Consider removing the re-raise at line 512-513 and letting AgentThreadExceptions propagate naturally, or handle them separately if you need to wrap them with additional context.

Suggested change
except AgentThreadException:
raise

Copilot uses AI. Check for mistakes.
except Exception as ex:
raise AgentThreadException("Failed to deserialize the message store.") from ex
return cls(message_store=message_store)
Expand Down Expand Up @@ -498,8 +538,35 @@ async def update_from_thread_state(
if state.chat_message_store_state is None:
return
if self.message_store is not None:
await self.message_store.add_messages(state.chat_message_store_state.messages, **kwargs)
# If we don't have a chat message store yet, create an in-memory one.
# Handle custom message stores (e.g., Redis) that need full state deserialization
try:
if isinstance(state.chat_message_store_state, dict):
# Custom store: use update_from_state method
await self.message_store.update_from_state(state.chat_message_store_state, **kwargs)
elif isinstance(state.chat_message_store_state, ChatMessageStoreState):
# Legacy ChatMessageStoreState: extract messages
await self.message_store.add_messages(state.chat_message_store_state.messages, **kwargs)
except Exception as ex:
raise AgentThreadException("Failed to update message store from state.") from ex
return
# Create the message store from the default.
self.message_store = ChatMessageStore(messages=state.chat_message_store_state.messages, **kwargs)

# No message_store exists, create one from the state
try:
if isinstance(state.chat_message_store_state, dict):
if "messages" in state.chat_message_store_state:
parsed_state = ChatMessageStoreState.from_dict(state.chat_message_store_state)
self.message_store = ChatMessageStore(messages=parsed_state.messages, **kwargs)
else:
raise AgentThreadException(
"Cannot create custom message store from state. "
"Please create a message store first and then call update_from_state()."
Comment on lines +561 to +562
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

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

The same issue exists here as in the deserialize method. The error message "Cannot create custom message store from state" is misleading because this branch is also reached for standard stores that happen to be stored as dicts. The check at line 556 verifies that 'messages' exists, so if it doesn't exist, you're treating it as a custom store error. However, it could also just be an invalid standard store state.

The error messaging should be more precise about what went wrong.

Suggested change
"Cannot create custom message store from state. "
"Please create a message store first and then call update_from_state()."
"Invalid chat_message_store_state: expected a 'messages' key when no message store "
"instance is configured. If you are using a custom message store, create it first and "
"then call update_from_thread_state()."

Copilot uses AI. Check for mistakes.
)
elif isinstance(state.chat_message_store_state, ChatMessageStoreState):
# Legacy ChatMessageStoreState object
self.message_store = ChatMessageStore(messages=state.chat_message_store_state.messages, **kwargs)
else:
raise AgentThreadException("Invalid chat_message_store_state type.")
except AgentThreadException:
raise
except Exception as ex:
Comment on lines +569 to +571
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

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

Same maintainability issue as in the deserialize method. The double exception handling with re-raise of AgentThreadException followed by a generic catch-all creates confusing control flow. Consider simplifying this error handling pattern.

Suggested change
except AgentThreadException:
raise
except Exception as ex:
except Exception as ex:
if isinstance(ex, AgentThreadException):
raise

Copilot uses AI. Check for mistakes.
raise AgentThreadException("Failed to create message store from state.") from ex
Loading