mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
0c67dbbce5
commit
1e350ea22f
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user