Python: [BREAKING] cleanup of thread API and serialization (#893)

* cleanup of threads and serialization

* fix for sliding window

* fix redis test

* updated from comments

* updated context provider and threads

* updated lock

* add asyncio default

* fix redis tests

* fix tests

* fix tests

* renamed to invoking

* fixed tests

* fix for instructions
This commit is contained in:
Eduard van Valkenburg
2025-09-29 18:22:34 +02:00
committed by GitHub
Unverified
parent bf5931932e
commit 10d10364a9
52 changed files with 1642 additions and 1411 deletions
@@ -63,7 +63,7 @@ The provider supports both fulltext only and hybrid vector search:
`redis_basics.py` walks through three scenarios:
1. Standalone provider usage: adds messages and retrieves context via `model_invoking`.
1. Standalone provider usage: adds messages and retrieves context via `invoking`.
2. Agent integration: teaches the agent a preference and verifies it is remembered across turns.
3. Agent + tool: calls a sample tool (flight search) and then asks the agent to recall details remembered from the tool output.
@@ -108,5 +108,3 @@ You should see the agent responses and, when using embeddings, context retrieved
- Ensure at least one of `application_id`, `agent_id`, `user_id`, or `thread_id` is set; the provider requires a scope.
- If using embeddings, verify `OPENAI_API_KEY` is set and reachable.
- Make sure Redis exposes RediSearch (Redis Stack image or managed service with search enabled).
@@ -27,21 +27,17 @@ Run:
python redis_basics.py
"""
import os
import asyncio
import os
from agent_framework import ChatMessage, Role
from agent_framework_redis._provider import RedisProvider
from agent_framework.openai import OpenAIChatClient
from redisvl.utils.vectorize import OpenAITextVectorizer
from agent_framework_redis._provider import RedisProvider
from redisvl.extensions.cache.embeddings import EmbeddingsCache
from redisvl.utils.vectorize import OpenAITextVectorizer
def search_flights(
origin_airport_code: str,
destination_airport_code: str,
detailed: bool = False
) -> str:
def search_flights(origin_airport_code: str, destination_airport_code: str, detailed: bool = False) -> str:
"""Simulated flight-search tool to demonstrate tool memory.
The agent can call this function, and the returned details can be stored
@@ -50,9 +46,27 @@ def search_flights(
"""
# Minimal static catalog used to simulate a tool's structured output
flights = {
("JFK", "LAX"): {"airline": "SkyJet", "duration": "6h 15m", "price": 325, "cabin": "Economy", "baggage": "1 checked bag"},
("SFO", "SEA"): {"airline": "Pacific Air", "duration": "2h 5m", "price": 129, "cabin": "Economy", "baggage": "Carry-on only"},
("LHR", "DXB"): {"airline": "EuroWings", "duration": "6h 50m", "price": 499, "cabin": "Business", "baggage": "2 bags included"},
("JFK", "LAX"): {
"airline": "SkyJet",
"duration": "6h 15m",
"price": 325,
"cabin": "Economy",
"baggage": "1 checked bag",
},
("SFO", "SEA"): {
"airline": "Pacific Air",
"duration": "2h 5m",
"price": 129,
"cabin": "Economy",
"baggage": "Carry-on only",
},
("LHR", "DXB"): {
"airline": "EuroWings",
"duration": "6h 50m",
"price": 499,
"cabin": "Business",
"baggage": "2 bags included",
},
}
route = (origin_airport_code.upper(), destination_airport_code.upper())
@@ -97,7 +111,7 @@ async def main() -> None:
)
# The provider manages persistence and retrieval. application_id/agent_id/user_id
# scope data for multi-tenant separation; thread_id (set later) narrows to a
# specific conversation.
# specific conversation.
provider = RedisProvider(
redis_url="redis://localhost:6379",
index_name="redis_basics",
@@ -109,7 +123,7 @@ async def main() -> None:
vector_algorithm="hnsw",
vector_distance_metric="cosine",
)
# Build sample chat messages to persist to Redis
messages = [
ChatMessage(role=Role.USER, text="runA CONVO: User Message"),
@@ -121,14 +135,12 @@ async def main() -> None:
# Threads are logical boundaries used by the provider to group and retrieve
# conversation-specific context.
await provider.thread_created(thread_id="runA")
await provider.messages_adding(thread_id="runA", new_messages=messages)
await provider.invoked(request_messages=messages)
# Retrieve relevant memories for a hypothetical model call. The provider uses
# the current request messages as the retrieval query and returns context to
# be injected into the model's instructions.
ctx = await provider.model_invoking([
ChatMessage(role=Role.SYSTEM, text="B: Assistant Message")
])
ctx = await provider.invoking([ChatMessage(role=Role.SYSTEM, text="B: Assistant Message")])
# Inspect retrieved memories that would be injected into instructions
# (Debug-only output so you can verify retrieval works as expected.)
@@ -167,13 +179,14 @@ async def main() -> None:
# Create agent wired to the Redis context provider. The provider automatically
# persists conversational details and surfaces relevant context on each turn.
agent = client.create_agent(
name="MemoryEnhancedAssistant",
instructions=(
"You are a helpful assistant. Personalize replies using provided context. "
"Before answering, always check for stored context"
),
tools=[],
context_providers=provider)
name="MemoryEnhancedAssistant",
instructions=(
"You are a helpful assistant. Personalize replies using provided context. "
"Before answering, always check for stored context"
),
tools=[],
context_providers=provider,
)
# Teach a user preference; the agent writes this to the provider's memory
query = "Remember that I enjoy glugenflorgle"
@@ -201,20 +214,21 @@ async def main() -> None:
prefix="context_3",
application_id="matrix_of_kermits",
agent_id="agent_kermit",
user_id="kermit"
user_id="kermit",
)
# Create agent exposing the flight search tool. Tool outputs are captured by the
# provider and become retrievable context for later turns.
client = OpenAIChatClient(ai_model_id=os.getenv("OPENAI_CHAT_MODEL_ID"), api_key=os.getenv("OPENAI_API_KEY"))
agent = client.create_agent(
name="MemoryEnhancedAssistant",
instructions=(
"You are a helpful assistant. Personalize replies using provided context. "
"Before answering, always check for stored context"
),
tools=search_flights,
context_providers=provider)
name="MemoryEnhancedAssistant",
instructions=(
"You are a helpful assistant. Personalize replies using provided context. "
"Before answering, always check for stored context"
),
tools=search_flights,
context_providers=provider,
)
# Invoke the tool; outputs become part of memory/context
query = "Are there any flights from new york city (jfk) to la? Give me details"
result = await agent.run(query)
@@ -229,5 +243,6 @@ async def main() -> None:
# Drop / delete the provider index in Redis
await provider.redis_index.delete()
if __name__ == "__main__":
asyncio.run(main())
@@ -2,7 +2,7 @@
"""Redis Context Provider: Basic usage and agent integration
This example demonstrates how to use the Redis ChatMessageStore to persist
This example demonstrates how to use the Redis ChatMessageStoreProtocol to persist
conversational details. Pass it as a constructor argument to create_agent.
Requirements:
@@ -14,15 +14,14 @@ Run:
python redis_conversation.py
"""
import os
import asyncio
import os
from agent_framework_redis._provider import RedisProvider
from agent_framework_redis._chat_message_store import RedisChatMessageStore
from agent_framework.openai import OpenAIChatClient
from redisvl.utils.vectorize import OpenAITextVectorizer
from agent_framework_redis._chat_message_store import RedisChatMessageStore
from agent_framework_redis._provider import RedisProvider
from redisvl.extensions.cache.embeddings import EmbeddingsCache
from redisvl.utils.vectorize import OpenAITextVectorizer
async def main() -> None:
@@ -65,15 +64,15 @@ async def main() -> None:
# Create agent wired to the Redis context provider. The provider automatically
# persists conversational details and surfaces relevant context on each turn.
agent = client.create_agent(
name="MemoryEnhancedAssistant",
instructions=(
"You are a helpful assistant. Personalize replies using provided context. "
"Before answering, always check for stored context"
),
tools=[],
context_providers=provider,
chat_message_store_factory=chat_message_store_factory,
)
name="MemoryEnhancedAssistant",
instructions=(
"You are a helpful assistant. Personalize replies using provided context. "
"Before answering, always check for stored context"
),
tools=[],
context_providers=provider,
chat_message_store_factory=chat_message_store_factory,
)
# Teach a user preference; the agent writes this to the provider's memory
query = "Remember that I enjoy gumbo"
@@ -109,5 +108,6 @@ async def main() -> None:
# Drop / delete the provider index in Redis
await provider.redis_index.delete()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,120 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from collections.abc import MutableSequence, Sequence
from typing import Any
from agent_framework import ChatAgent, ChatClientProtocol, ChatMessage, ChatOptions, Context, ContextProvider
from agent_framework.azure import AzureAIAgentClient
from azure.identity.aio import AzureCliCredential
from pydantic import BaseModel
class UserInfo(BaseModel):
name: str | None = None
age: int | None = None
class UserInfoMemory(ContextProvider):
def __init__(self, chat_client: ChatClientProtocol, user_info: UserInfo | None = None, **kwargs: Any):
"""Create the memory.
If you pass in kwargs, they will be attempted to be used to create a UserInfo object.
"""
self._chat_client = chat_client
if user_info:
self.user_info = user_info
elif kwargs:
self.user_info = UserInfo.model_validate(kwargs)
else:
self.user_info = UserInfo()
async def invoked(
self,
request_messages: ChatMessage | Sequence[ChatMessage],
response_messages: ChatMessage | Sequence[ChatMessage] | None = None,
invoke_exception: Exception | None = None,
**kwargs: Any,
) -> None:
"""Extract user information from messages after each agent call."""
# Check if we need to extract user info from user messages
user_messages = [msg for msg in request_messages if hasattr(msg, "role") and msg.role.value == "user"] # type: ignore
if (self.user_info.name is None or self.user_info.age is None) and user_messages:
try:
# Use the chat client to extract structured information
result = await self._chat_client.get_response(
messages=request_messages, # type: ignore
chat_options=ChatOptions(
instructions="Extract the user's name and age from the message if present. If not present return nulls.",
response_format=UserInfo,
),
)
# Update user info with extracted data
if result.value:
if self.user_info.name is None and result.value.name:
self.user_info.name = result.value.name
if self.user_info.age is None and result.value.age:
self.user_info.age = result.value.age
except Exception:
pass # Failed to extract, continue without updating
async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context:
"""Provide user information context before each agent call."""
instructions: list[str] = []
if self.user_info.name is None:
instructions.append(
"Ask the user for their name and politely decline to answer any questions until they provide it."
)
else:
instructions.append(f"The user's name is {self.user_info.name}.")
if self.user_info.age is None:
instructions.append(
"Ask the user for their age and politely decline to answer any questions until they provide it."
)
else:
instructions.append(f"The user's age is {self.user_info.age}.")
# Return context with additional instructions
return Context(instructions=" ".join(instructions))
def serialize(self) -> str:
"""Serialize the user info for thread persistence."""
return self.user_info.model_dump_json()
async def main():
async with AzureCliCredential() as credential:
chat_client = AzureAIAgentClient(async_credential=credential)
# Create the memory provider
memory_provider = UserInfoMemory(chat_client)
# Create the agent with memory
async with ChatAgent(
chat_client=chat_client,
instructions="You are a friendly assistant. Always address the user by their name.",
context_providers=memory_provider,
) as agent:
# Create a new thread for the conversation
thread = agent.get_new_thread()
print(await agent.run("Hello, what is the square root of 9?", thread=thread))
print(await agent.run("My name is Ruaidhrí", thread=thread))
print(await agent.run("I am 20 years old", thread=thread))
# Access the memory component via the thread's get_service method and inspect the memories
user_info_memory = thread.context_provider.providers[0] # type: ignore
if user_info_memory:
print()
print(f"MEMORY - User Name: {user_info_memory.user_info.name}") # type: ignore
print(f"MEMORY - User Age: {user_info_memory.user_info.age}") # type: ignore
if __name__ == "__main__":
asyncio.run(main())