Python: [BREAKING] PR2 — Wire context provider pipeline, remove old types, update all consumers (#3850)

* PR2: Wire context provider pipeline and update all internal consumers

- Replace AgentThread with AgentSession across all packages
- Replace ContextProvider with BaseContextProvider across all packages
- Replace context_provider param with context_providers (Sequence)
- Replace thread= with session= in run() signatures
- Replace get_new_thread() with create_session()
- Add get_session(service_session_id) to agent interface
- DurableAgentThread -> DurableAgentSession
- Remove _notify_thread_of_new_messages from WorkflowAgent
- Wire before_run/after_run context provider pipeline in RawAgent
- Auto-inject InMemoryHistoryProvider when no providers configured

* fix: update all tests for context provider pipeline, fix lazy-loaders, remove old test files

* refactor: update all sample files for context provider pipeline (AgentThread→AgentSession, ContextProvider→BaseContextProvider)

* fix: update remaining ag-ui references (client docstring, getting_started sample)

* fix: make get_session service_session_id keyword-only to avoid confusion with session_id

* refactor: rename _RunContext.thread_messages to session_messages

* refactor: remove _threads.py, _memory.py, and old provider files; migrate devui to use plain message lists

* rename: remove _new_ prefix from test files

* refactor: rewrite SlidingWindowChatMessageStore as SlidingWindowHistoryProvider(InMemoryHistoryProvider)

* fix: read full history from session state directly instead of reaching into provider internals

* fix: update stale .pyi stubs, sample imports, and README references for new provider types

* fix: remove stale message_store, _notify_thread_of_new_messages, and session_id.key references in samples

* refactor: merge context_providers and sessions sample folders into sessions, remove aggregate_context_provider

* refactor: UserInfoMemory stores state in session.state instead of instance attributes

* feat: add Pydantic BaseModel support to session state serialization

Pydantic models stored in session.state are now automatically serialized
via model_dump() and restored via model_validate() during to_dict()/from_dict()
round-trips. Models are auto-registered on first serialization; use
register_state_type() for cold-start deserialization.

Also export register_state_type as a public API.

* fix mem0

* Update sample README links and descriptions for session terminology

- Replace 'thread' with 'session' in sample descriptions across all READMEs
- Update file links for renamed samples (mem0_sessions, redis_sessions, etc.)
- Fix Threads section → Sessions section in main samples/README.md
- Update tools, middleware, workflows, durabletask, azure_functions READMEs
- Update architecture diagrams in concepts/tools/README.md
- Update migration guides (autogen, semantic-kernel)

* Fix broken Redis README link to renamed sample

* Fix Mem0 OSS client search: pass scoping params as direct kwargs

AsyncMemory (OSS) expects user_id/agent_id/run_id as direct kwargs,
while AsyncMemoryClient (Platform) expects them in a filters dict.
Adds tests for both client types.

Port of fix from #3844 to new Mem0ContextProvider.

* Fix rebase issues: restore missing _conversation_state.py and checkpoint decode logic

- Add back _conversation_state.py (encode/decode_chat_messages) lost in rebase
- Fix on_checkpoint_restore to decode cache/conversation with decode_chat_messages
- Fix on_checkpoint_restore to use decode_checkpoint_value for pending requests
- Add tests/workflow/__init__.py for relative import support
- Fix test_agent_executor checkpoint selection (checkpoints[1] not superstep)

* Add STORES_BY_DEFAULT ClassVar to skip redundant InMemoryHistoryProvider injection

Chat clients that store history server-side by default (OpenAI Responses API,
Azure AI Agent) now declare STORES_BY_DEFAULT = True. The agent checks this
during auto-injection and skips InMemoryHistoryProvider unless the user
explicitly sets store=False.

* Fix broken markdown links in azure_ai and redis READMEs

* Fix getting-started samples to use session API instead of removed thread/ContextProvider API

* updates to workflow as agent

* fix group chat import

* Rename Thread→Session throughout, fix service_session_id propagation, remove stale AGUIThread

- Fix: Propagate conversation_id from ChatResponse back to session.service_session_id
  in both streaming and non-streaming paths in _agents.py
- Rename AgentThreadException → AgentSessionException
- Remove stale AGUIThread from ag_ui lazy-loader
- Rename use_service_thread → use_service_session in ag-ui package
- Rename test functions from *_thread_* to *_session_*
- Rename sample files from *_thread* to *_session*
- Update docstrings and comments: thread → session
- Update _mcp.py kwargs filter: add 'session' alongside 'thread'
- Fix ContinuationToken docstring example: thread=thread → session=session
- Fix _clients.py docstring: 'Agent threads' → 'Agent sessions'

* Fix broken markdown links after thread→session file renames

* fix azure ai test
This commit is contained in:
Eduard van Valkenburg
2026-02-12 22:00:32 +01:00
committed by GitHub
Unverified
parent 0c67dbbce5
commit 1e350ea22f
312 changed files with 6669 additions and 11423 deletions
@@ -3,7 +3,7 @@
"""Conversation storage abstraction for OpenAI Conversations API.
This module provides a clean abstraction layer for managing conversations
while wrapping AgentFramework's AgentThread underneath.
with in-memory message storage.
"""
from __future__ import annotations
@@ -13,7 +13,7 @@ import uuid
from abc import ABC, abstractmethod
from typing import Any, Literal, cast
from agent_framework import AgentThread, Message
from agent_framework import AgentSession, Message
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
from openai.types.conversations import Conversation, ConversationDeletedResource
from openai.types.conversations.conversation_item import ConversationItem
@@ -38,14 +38,14 @@ class ConversationStore(ABC):
"""Abstract base class for conversation storage.
Provides OpenAI Conversations API interface while managing
AgentThread instances underneath.
message storage internally.
"""
@abstractmethod
def create_conversation(
self, metadata: dict[str, str] | None = None, conversation_id: str | None = None
) -> Conversation:
"""Create a new conversation (wraps AgentThread creation).
"""Create a new conversation.
Args:
metadata: Optional metadata dict (e.g., {"agent_id": "weather_agent"})
@@ -86,7 +86,7 @@ class ConversationStore(ABC):
@abstractmethod
def delete_conversation(self, conversation_id: str) -> ConversationDeletedResource:
"""Delete conversation (including AgentThread).
"""Delete conversation.
Args:
conversation_id: Conversation ID
@@ -101,7 +101,7 @@ class ConversationStore(ABC):
@abstractmethod
async def add_items(self, conversation_id: str, items: list[dict[str, Any]]) -> list[ConversationItem]:
"""Add items to conversation (syncs to AgentThread.message_store).
"""Add items to conversation.
Args:
conversation_id: Conversation ID
@@ -119,7 +119,7 @@ class ConversationStore(ABC):
async def list_items(
self, conversation_id: str, limit: int = 100, after: str | None = None, order: str = "asc"
) -> tuple[list[ConversationItem], bool]:
"""List conversation items from AgentThread.message_store.
"""List conversation items.
Args:
conversation_id: Conversation ID
@@ -152,17 +152,17 @@ class ConversationStore(ABC):
pass
@abstractmethod
def get_thread(self, conversation_id: str) -> AgentThread | None:
"""Get underlying AgentThread for execution (internal use).
def get_session(self, conversation_id: str) -> AgentSession | None:
"""Get AgentSession for agent execution.
This is the critical method that allows the executor to get the
AgentThread for running agents with conversation context.
AgentSession for running agents with conversation context.
Args:
conversation_id: Conversation ID
Returns:
AgentThread object or None if not found
AgentSession object or None if not found
"""
pass
@@ -183,7 +183,7 @@ class ConversationStore(ABC):
"""Add a trace event to the conversation for context inspection.
Traces capture execution metadata like token usage, timing, and LLM context
that isn't stored in the AgentThread but is useful for debugging.
that is useful for debugging.
Args:
conversation_id: Conversation ID
@@ -205,17 +205,17 @@ class ConversationStore(ABC):
class InMemoryConversationStore(ConversationStore):
"""In-memory conversation storage wrapping AgentThread.
"""In-memory conversation storage.
This implementation stores conversations in memory with their
underlying AgentThread instances for execution.
underlying message lists and AgentSession instances for execution.
"""
def __init__(self) -> None:
"""Initialize in-memory conversation storage.
Storage structure maps conversation IDs to conversation data including
the underlying AgentThread, metadata, and cached ConversationItems.
messages, metadata, and cached ConversationItems.
"""
self._conversations: dict[str, dict[str, Any]] = {}
@@ -225,20 +225,22 @@ class InMemoryConversationStore(ConversationStore):
def create_conversation(
self, metadata: dict[str, str] | None = None, conversation_id: str | None = None
) -> Conversation:
"""Create a new conversation with underlying AgentThread and checkpoint storage."""
"""Create a new conversation with message storage and checkpoint storage."""
conv_id = conversation_id or f"conv_{uuid.uuid4().hex}"
created_at = int(time.time())
# Create AgentThread with default ChatMessageStore
thread = AgentThread()
# Create message list for internal storage and AgentSession for execution
messages: list[Message] = []
session = AgentSession(session_id=conv_id)
# Create session-scoped checkpoint storage (one per conversation)
checkpoint_storage = InMemoryCheckpointStorage()
self._conversations[conv_id] = {
"id": conv_id,
"thread": thread,
"checkpoint_storage": checkpoint_storage, # Stored alongside thread
"messages": messages,
"session": session,
"checkpoint_storage": checkpoint_storage,
"metadata": metadata or {},
"created_at": created_at,
"items": [],
@@ -279,7 +281,7 @@ class InMemoryConversationStore(ConversationStore):
)
def delete_conversation(self, conversation_id: str) -> ConversationDeletedResource:
"""Delete conversation and its AgentThread."""
"""Delete conversation."""
if conversation_id not in self._conversations:
raise ValueError(f"Conversation {conversation_id} not found")
@@ -290,14 +292,14 @@ class InMemoryConversationStore(ConversationStore):
return ConversationDeletedResource(id=conversation_id, object="conversation.deleted", deleted=True)
async def add_items(self, conversation_id: str, items: list[dict[str, Any]]) -> list[ConversationItem]:
"""Add items to conversation and sync to AgentThread."""
"""Add items to conversation."""
conv_data = self._conversations.get(conversation_id)
if not conv_data:
raise ValueError(f"Conversation {conversation_id} not found")
thread: AgentThread = conv_data["thread"]
stored_messages: list[Message] = conv_data["messages"]
# Convert items to ChatMessages and add to thread
# Convert items to Messages and add to storage
chat_messages = []
for item in items:
# Simple conversion - assume text content for now
@@ -308,8 +310,8 @@ class InMemoryConversationStore(ConversationStore):
chat_msg = Message(role=role, text=text) # type: ignore[arg-type]
chat_messages.append(chat_msg)
# Add messages to AgentThread
await thread.on_new_messages(chat_messages)
# Add messages to internal storage
stored_messages.extend(chat_messages)
# Create Message objects (ConversationItem is a Union - use concrete Message type)
conv_items: list[ConversationItem] = []
@@ -354,9 +356,9 @@ class InMemoryConversationStore(ConversationStore):
async def list_items(
self, conversation_id: str, limit: int = 100, after: str | None = None, order: str = "asc"
) -> tuple[list[ConversationItem], bool]:
"""List conversation items from AgentThread message store.
"""List conversation items.
Converts AgentFramework ChatMessages to proper OpenAI ConversationItem types:
Converts stored Messages to proper OpenAI ConversationItem types:
- Messages with text/images/files → Message
- Function calls → ResponseFunctionToolCallItem
- Function results → ResponseFunctionToolCallOutputItem
@@ -365,119 +367,114 @@ class InMemoryConversationStore(ConversationStore):
if not conv_data:
raise ValueError(f"Conversation {conversation_id} not found")
thread: AgentThread = conv_data["thread"]
stored_messages: list[Message] = conv_data["messages"]
# Get messages from thread's message store
# Convert stored messages to ConversationItem types
items: list[ConversationItem] = []
if thread.message_store:
af_messages = await thread.message_store.list_messages()
af_messages = stored_messages
# Convert each AgentFramework Message to appropriate ConversationItem type(s)
for i, msg in enumerate(af_messages):
item_id = f"item_{i}"
role_str = msg.role if hasattr(msg.role, "value") else str(msg.role)
role = cast(MessageRole, role_str) # Safe: Agent Framework roles match OpenAI roles
# Convert each AgentFramework Message to appropriate ConversationItem type(s)
for i, msg in enumerate(af_messages):
item_id = f"item_{i}"
role_str = msg.role if hasattr(msg.role, "value") else str(msg.role)
role = cast(MessageRole, role_str) # Safe: Agent Framework roles match OpenAI roles
# Process each content item in the message
# A single Message may produce multiple ConversationItems
# (e.g., a message with both text and a function call)
message_contents: list[TextContent | ResponseInputImage | ResponseInputFile] = []
function_calls = []
function_results = []
# Process each content item in the message
# A single Message may produce multiple ConversationItems
# (e.g., a message with both text and a function call)
message_contents: list[TextContent | ResponseInputImage | ResponseInputFile] = []
function_calls = []
function_results = []
for content in msg.contents:
content_type = getattr(content, "type", None)
for content in msg.contents:
content_type = getattr(content, "type", None)
if content_type == "text":
# Text content for Message
text_value = getattr(content, "text", "")
message_contents.append(TextContent(type="text", text=text_value))
if content_type == "text":
# Text content for Message
text_value = getattr(content, "text", "")
message_contents.append(TextContent(type="text", text=text_value))
elif content_type == "data":
# Data content (images, files, PDFs)
uri = getattr(content, "uri", "")
media_type = getattr(content, "media_type", None)
elif content_type == "data":
# Data content (images, files, PDFs)
uri = getattr(content, "uri", "")
media_type = getattr(content, "media_type", None)
if media_type and media_type.startswith("image/"):
# Convert to ResponseInputImage
message_contents.append(
ResponseInputImage(type="input_image", image_url=uri, detail="auto")
if media_type and media_type.startswith("image/"):
# Convert to ResponseInputImage
message_contents.append(ResponseInputImage(type="input_image", image_url=uri, detail="auto"))
else:
# Convert to ResponseInputFile
# Extract filename from URI if possible
filename = None
if media_type == "application/pdf":
filename = "document.pdf"
message_contents.append(ResponseInputFile(type="input_file", file_url=uri, filename=filename))
elif content_type == "function_call":
# Function call - create separate ConversationItem
call_id = getattr(content, "call_id", None)
name = getattr(content, "name", "")
arguments = getattr(content, "arguments", "")
if call_id and name:
function_calls.append(
ResponseFunctionToolCallItem(
id=f"{item_id}_call_{call_id}",
call_id=call_id,
name=name,
arguments=arguments,
type="function_call",
status="completed",
)
else:
# Convert to ResponseInputFile
# Extract filename from URI if possible
filename = None
if media_type == "application/pdf":
filename = "document.pdf"
)
message_contents.append(
ResponseInputFile(type="input_file", file_url=uri, filename=filename)
elif content_type == "function_result":
# Function result - create separate ConversationItem
call_id = getattr(content, "call_id", None)
# Output is stored in the 'result' field of FunctionResultContent
result_value = getattr(content, "result", None)
# Convert result to string (it could be dict, list, or other types)
if result_value is None:
output = ""
elif isinstance(result_value, str):
output = result_value
else:
import json
try:
output = json.dumps(result_value)
except (TypeError, ValueError):
output = str(result_value)
if call_id:
function_results.append(
ResponseFunctionToolCallOutputItem(
id=f"{item_id}_result_{call_id}",
call_id=call_id,
output=output,
type="function_call_output",
status="completed",
)
)
elif content_type == "function_call":
# Function call - create separate ConversationItem
call_id = getattr(content, "call_id", None)
name = getattr(content, "name", "")
arguments = getattr(content, "arguments", "")
# Create ConversationItems based on what we found
# If message has text/images/files, create a Message item
if message_contents:
message = OpenAIMessage(
id=item_id,
type="message",
role=role, # type: ignore
content=message_contents, # type: ignore
status="completed",
)
items.append(message)
if call_id and name:
function_calls.append(
ResponseFunctionToolCallItem(
id=f"{item_id}_call_{call_id}",
call_id=call_id,
name=name,
arguments=arguments,
type="function_call",
status="completed",
)
)
# Add function call items
items.extend(function_calls)
elif content_type == "function_result":
# Function result - create separate ConversationItem
call_id = getattr(content, "call_id", None)
# Output is stored in the 'result' field of FunctionResultContent
result_value = getattr(content, "result", None)
# Convert result to string (it could be dict, list, or other types)
if result_value is None:
output = ""
elif isinstance(result_value, str):
output = result_value
else:
import json
try:
output = json.dumps(result_value)
except (TypeError, ValueError):
output = str(result_value)
if call_id:
function_results.append(
ResponseFunctionToolCallOutputItem(
id=f"{item_id}_result_{call_id}",
call_id=call_id,
output=output,
type="function_call_output",
status="completed",
)
)
# Create ConversationItems based on what we found
# If message has text/images/files, create a Message item
if message_contents:
message = OpenAIMessage(
id=item_id,
type="message",
role=role, # type: ignore
content=message_contents, # type: ignore
status="completed",
)
items.append(message)
# Add function call items
items.extend(function_calls)
# Add function result items
items.extend(function_results)
# Add function result items
items.extend(function_results)
# Include checkpoints from checkpoint storage as conversation items
checkpoint_storage = conv_data.get("checkpoint_storage")
@@ -589,16 +586,16 @@ class InMemoryConversationStore(ConversationStore):
return None
def get_thread(self, conversation_id: str) -> AgentThread | None:
"""Get AgentThread for execution - CRITICAL for agent.run()."""
def get_session(self, conversation_id: str) -> AgentSession | None:
"""Get AgentSession for execution - CRITICAL for agent.run()."""
conv_data = self._conversations.get(conversation_id)
return conv_data["thread"] if conv_data else None
return conv_data["session"] if conv_data else None
def add_trace(self, conversation_id: str, trace_event: dict[str, Any]) -> None:
"""Add a trace event to the conversation for context inspection.
Traces capture execution metadata like token usage, timing, and LLM context
that isn't stored in the AgentThread but is useful for debugging.
that is useful for debugging.
Args:
conversation_id: Conversation ID
@@ -308,15 +308,15 @@ class AgentFrameworkExecutor:
# Convert input to proper Message or string
user_message = self._convert_input_to_chat_message(request.input)
# Get thread from conversation parameter (OpenAI standard!)
thread = None
# Get session from conversation parameter (OpenAI standard!)
session = None
conversation_id = request._get_conversation_id()
if conversation_id:
thread = self.conversation_store.get_thread(conversation_id)
if thread:
session = self.conversation_store.get_session(conversation_id)
if session:
logger.debug(f"Using existing conversation: {conversation_id}")
else:
logger.warning(f"Conversation {conversation_id} not found, proceeding without thread")
logger.warning(f"Conversation {conversation_id} not found, proceeding without session")
if isinstance(user_message, str):
logger.debug(f"Executing agent with text input: {user_message[:100]}...")
@@ -331,8 +331,8 @@ class AgentFrameworkExecutor:
# Agent must have run() method - use stream=True for streaming
if hasattr(agent, "run") and callable(agent.run):
# Use Agent Framework's run() with stream=True for streaming
if thread:
async for update in agent.run(user_message, stream=True, thread=thread):
if session:
async for update in agent.run(user_message, stream=True, session=session):
for trace_event in trace_collector.get_pending_events():
yield trace_event