chore: deprecate old agent messaging (#9120)

This commit is contained in:
Sarah Wooders
2026-02-02 22:55:35 -08:00
committed by Caren Thomas
parent cd7e80acc3
commit 3fdf2b6c79
9 changed files with 205 additions and 1117 deletions

View File

@@ -9559,12 +9559,12 @@
} }
} }
}, },
"/v1/groups/{group_id}/messages": { "/v1/groups/{group_id}/messages/{message_id}": {
"post": { "patch": {
"tags": ["groups"], "tags": ["groups"],
"summary": "Send Group Message", "summary": "Modify Group Message",
"description": "Process a user message and return the group's response.\nThis endpoint accepts a message from a user and processes it through through agents in the group based on the specified pattern", "description": "Update the details of a message associated with an agent.",
"operationId": "send_group_message", "operationId": "modify_group_message",
"deprecated": true, "deprecated": true,
"parameters": [ "parameters": [
{ {
@@ -9581,6 +9581,21 @@
"title": "Group Id" "title": "Group Id"
}, },
"description": "The ID of the group in the format 'group-<uuid4>'" "description": "The ID of the group in the format 'group-<uuid4>'"
},
{
"name": "message_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"minLength": 44,
"maxLength": 44,
"pattern": "^message-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
"description": "The ID of the message in the format 'message-<uuid4>'",
"examples": ["message-123e4567-e89b-42d3-8456-426614174000"],
"title": "Message Id"
},
"description": "The ID of the message in the format 'message-<uuid4>'"
} }
], ],
"requestBody": { "requestBody": {
@@ -9588,7 +9603,21 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/LettaRequest" "anyOf": [
{
"$ref": "#/components/schemas/UpdateSystemMessage"
},
{
"$ref": "#/components/schemas/UpdateUserMessage"
},
{
"$ref": "#/components/schemas/UpdateReasoningMessage"
},
{
"$ref": "#/components/schemas/UpdateAssistantMessage"
}
],
"title": "Request"
} }
} }
} }
@@ -9599,7 +9628,58 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/LettaResponse" "oneOf": [
{
"$ref": "#/components/schemas/SystemMessage"
},
{
"$ref": "#/components/schemas/UserMessage"
},
{
"$ref": "#/components/schemas/ReasoningMessage"
},
{
"$ref": "#/components/schemas/HiddenReasoningMessage"
},
{
"$ref": "#/components/schemas/ToolCallMessage"
},
{
"$ref": "#/components/schemas/ToolReturnMessage"
},
{
"$ref": "#/components/schemas/AssistantMessage"
},
{
"$ref": "#/components/schemas/ApprovalRequestMessage"
},
{
"$ref": "#/components/schemas/ApprovalResponseMessage"
},
{
"$ref": "#/components/schemas/SummaryMessage"
},
{
"$ref": "#/components/schemas/EventMessage"
}
],
"discriminator": {
"propertyName": "message_type",
"mapping": {
"system_message": "#/components/schemas/SystemMessage",
"user_message": "#/components/schemas/UserMessage",
"reasoning_message": "#/components/schemas/ReasoningMessage",
"hidden_reasoning_message": "#/components/schemas/HiddenReasoningMessage",
"tool_call_message": "#/components/schemas/ToolCallMessage",
"tool_return_message": "#/components/schemas/ToolReturnMessage",
"assistant_message": "#/components/schemas/AssistantMessage",
"approval_request_message": "#/components/schemas/ApprovalRequestMessage",
"approval_response_message": "#/components/schemas/ApprovalResponseMessage",
"summary_message": "#/components/schemas/SummaryMessage",
"event_message": "#/components/schemas/EventMessage"
}
},
"title": "Response Modify Group Message"
} }
} }
} }
@@ -9615,7 +9695,9 @@
} }
} }
} }
}, }
},
"/v1/groups/{group_id}/messages": {
"get": { "get": {
"tags": ["groups"], "tags": ["groups"],
"summary": "List Group Messages", "summary": "List Group Messages",
@@ -9790,203 +9872,6 @@
} }
} }
}, },
"/v1/groups/{group_id}/messages/stream": {
"post": {
"tags": ["groups"],
"summary": "Send Group Message Streaming",
"description": "Process a user message and return the group's responses.\nThis endpoint accepts a message from a user and processes it through agents in the group based on the specified pattern.\nIt will stream the steps of the response always, and stream the tokens if 'stream_tokens' is set to True.",
"operationId": "send_group_message_streaming",
"deprecated": true,
"parameters": [
{
"name": "group_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"minLength": 42,
"maxLength": 42,
"pattern": "^group-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
"description": "The ID of the group in the format 'group-<uuid4>'",
"examples": ["group-123e4567-e89b-42d3-8456-426614174000"],
"title": "Group Id"
},
"description": "The ID of the group in the format 'group-<uuid4>'"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LettaStreamingRequest"
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {}
},
"text/event-stream": {
"description": "Server-Sent Events stream"
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/groups/{group_id}/messages/{message_id}": {
"patch": {
"tags": ["groups"],
"summary": "Modify Group Message",
"description": "Update the details of a message associated with an agent.",
"operationId": "modify_group_message",
"deprecated": true,
"parameters": [
{
"name": "group_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"minLength": 42,
"maxLength": 42,
"pattern": "^group-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
"description": "The ID of the group in the format 'group-<uuid4>'",
"examples": ["group-123e4567-e89b-42d3-8456-426614174000"],
"title": "Group Id"
},
"description": "The ID of the group in the format 'group-<uuid4>'"
},
{
"name": "message_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"minLength": 44,
"maxLength": 44,
"pattern": "^message-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
"description": "The ID of the message in the format 'message-<uuid4>'",
"examples": ["message-123e4567-e89b-42d3-8456-426614174000"],
"title": "Message Id"
},
"description": "The ID of the message in the format 'message-<uuid4>'"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/UpdateSystemMessage"
},
{
"$ref": "#/components/schemas/UpdateUserMessage"
},
{
"$ref": "#/components/schemas/UpdateReasoningMessage"
},
{
"$ref": "#/components/schemas/UpdateAssistantMessage"
}
],
"title": "Request"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SystemMessage"
},
{
"$ref": "#/components/schemas/UserMessage"
},
{
"$ref": "#/components/schemas/ReasoningMessage"
},
{
"$ref": "#/components/schemas/HiddenReasoningMessage"
},
{
"$ref": "#/components/schemas/ToolCallMessage"
},
{
"$ref": "#/components/schemas/ToolReturnMessage"
},
{
"$ref": "#/components/schemas/AssistantMessage"
},
{
"$ref": "#/components/schemas/ApprovalRequestMessage"
},
{
"$ref": "#/components/schemas/ApprovalResponseMessage"
},
{
"$ref": "#/components/schemas/SummaryMessage"
},
{
"$ref": "#/components/schemas/EventMessage"
}
],
"discriminator": {
"propertyName": "message_type",
"mapping": {
"system_message": "#/components/schemas/SystemMessage",
"user_message": "#/components/schemas/UserMessage",
"reasoning_message": "#/components/schemas/ReasoningMessage",
"hidden_reasoning_message": "#/components/schemas/HiddenReasoningMessage",
"tool_call_message": "#/components/schemas/ToolCallMessage",
"tool_return_message": "#/components/schemas/ToolReturnMessage",
"assistant_message": "#/components/schemas/AssistantMessage",
"approval_request_message": "#/components/schemas/ApprovalRequestMessage",
"approval_response_message": "#/components/schemas/ApprovalResponseMessage",
"summary_message": "#/components/schemas/SummaryMessage",
"event_message": "#/components/schemas/EventMessage"
}
},
"title": "Response Modify Group Message"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/groups/{group_id}/reset-messages": { "/v1/groups/{group_id}/reset-messages": {
"patch": { "patch": {
"tags": ["groups"], "tags": ["groups"],

View File

@@ -1,18 +1,15 @@
import asyncio import asyncio
import json
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import TYPE_CHECKING, List from typing import TYPE_CHECKING, List
from letta.functions.helpers import ( from letta.functions.helpers import (
_send_message_to_agents_matching_tags_async,
_send_message_to_all_agents_in_group_async, _send_message_to_all_agents_in_group_async,
execute_send_message_to_agent, execute_send_message_to_agent,
extract_send_message_from_steps_messages,
fire_and_forget_send_to_agent, fire_and_forget_send_to_agent,
) )
from letta.schemas.enums import MessageRole from letta.schemas.enums import MessageRole
from letta.schemas.message import MessageCreate from letta.schemas.message import MessageCreate
from letta.server.rest_api.dependencies import get_letta_server from letta.server.rest_api.dependencies import get_letta_server
from letta.settings import settings
def send_message_to_agent_and_wait_for_reply(self: "Agent", message: str, other_agent_id: str) -> str: def send_message_to_agent_and_wait_for_reply(self: "Agent", message: str, other_agent_id: str) -> str:
@@ -67,46 +64,11 @@ def send_message_to_agents_matching_tags(self: "Agent", message: str, match_all:
if not matching_agents: if not matching_agents:
return [] return []
def process_agent(agent_id: str) -> str: # Prepare the message
"""Loads an agent, formats the message, and executes .step()""" messages = [MessageCreate(role=MessageRole.system, content=augmented_message, name=self.agent_state.name)]
actor = self.user # Ensure correct actor context
agent = server.load_agent(agent_id=agent_id, interface=None, actor=actor)
# Prepare the message # Use async helper for parallel message sending
messages = [MessageCreate(role=MessageRole.system, content=augmented_message, name=self.agent_state.name)] return asyncio.run(_send_message_to_agents_matching_tags_async(self, server, messages, matching_agents))
# Run .step() and return the response
usage_stats = agent.step(
input_messages=messages,
chaining=True,
max_chaining_steps=None,
stream=False,
skip_verify=True,
metadata=None,
put_inner_thoughts_first=True,
)
send_messages = extract_send_message_from_steps_messages(usage_stats.steps_messages, logger=agent.logger)
response_data = {
"agent_id": agent_id,
"response_messages": send_messages if send_messages else ["<no response>"],
}
return json.dumps(response_data, indent=2)
# Use ThreadPoolExecutor for parallel execution
results = []
with ThreadPoolExecutor(max_workers=settings.multi_agent_concurrent_sends) as executor:
future_to_agent = {executor.submit(process_agent, agent_state.id): agent_state for agent_state in matching_agents}
for future in as_completed(future_to_agent):
try:
results.append(future.result()) # Collect results
except Exception as e:
# Log or handle failure for specific agents if needed
self.logger.exception(f"Error processing agent {future_to_agent[future]}: {e}")
return results
def send_message_to_all_agents_in_group(self: "Agent", message: str) -> List[str]: def send_message_to_all_agents_in_group(self: "Agent", message: str) -> List[str]:

View File

@@ -1557,23 +1557,6 @@ async def send_message(
# Create a copy of agent state with the overridden llm_config # Create a copy of agent state with the overridden llm_config
agent = agent.model_copy(update={"llm_config": override_llm_config}) agent = agent.model_copy(update={"llm_config": override_llm_config})
agent_eligible = agent.multi_agent_group is None or agent.multi_agent_group.manager_type in ["sleeptime", "voice_sleeptime"]
model_compatible = agent.llm_config.model_endpoint_type in [
"anthropic",
"openai",
"together",
"google_ai",
"google_vertex",
"bedrock",
"ollama",
"azure",
"xai",
"zai",
"groq",
"deepseek",
"chatgpt_oauth",
]
# Create a new run for execution tracking # Create a new run for execution tracking
if settings.track_agent_run: if settings.track_agent_run:
runs_manager = RunManager() runs_manager = RunManager()
@@ -1597,32 +1580,17 @@ async def send_message(
run_update_metadata = None run_update_metadata = None
try: try:
result = None agent_loop = AgentLoop.load(agent_state=agent, actor=actor)
if agent_eligible and model_compatible: result = await agent_loop.step(
agent_loop = AgentLoop.load(agent_state=agent, actor=actor) request.messages,
result = await agent_loop.step( max_steps=request.max_steps,
request.messages, run_id=run.id if run else None,
max_steps=request.max_steps, use_assistant_message=request.use_assistant_message,
run_id=run.id if run else None, request_start_timestamp_ns=request_start_timestamp_ns,
use_assistant_message=request.use_assistant_message, include_return_message_types=request.include_return_message_types,
request_start_timestamp_ns=request_start_timestamp_ns, client_tools=request.client_tools,
include_return_message_types=request.include_return_message_types, include_compaction_messages=request.include_compaction_messages,
client_tools=request.client_tools, )
include_compaction_messages=request.include_compaction_messages,
)
else:
result = await server.send_message_to_agent(
agent_id=agent_id,
actor=actor,
input_messages=request.messages,
stream_steps=False,
stream_tokens=False,
# Support for AssistantMessage
use_assistant_message=request.use_assistant_message,
assistant_message_tool_name=request.assistant_message_tool_name,
assistant_message_tool_kwarg=request.assistant_message_tool_kwarg,
include_return_message_types=request.include_return_message_types,
)
run_status = result.stop_reason.stop_reason.run_status run_status = result.stop_reason.stop_reason.run_status
return result return result
except PendingApprovalError as e: except PendingApprovalError as e:
@@ -1844,47 +1812,16 @@ async def _process_message_background(
# Create a copy of agent state with the overridden llm_config # Create a copy of agent state with the overridden llm_config
agent = agent.model_copy(update={"llm_config": override_llm_config}) agent = agent.model_copy(update={"llm_config": override_llm_config})
agent_eligible = agent.multi_agent_group is None or agent.multi_agent_group.manager_type in ["sleeptime", "voice_sleeptime"] agent_loop = AgentLoop.load(agent_state=agent, actor=actor)
model_compatible = agent.llm_config.model_endpoint_type in [ result = await agent_loop.step(
"anthropic", messages,
"openai", max_steps=max_steps,
"together", run_id=run_id,
"google_ai", use_assistant_message=use_assistant_message,
"google_vertex", request_start_timestamp_ns=request_start_timestamp_ns,
"bedrock", include_return_message_types=include_return_message_types,
"ollama", include_compaction_messages=include_compaction_messages,
"azure", )
"xai",
"zai",
"groq",
"deepseek",
]
if agent_eligible and model_compatible:
agent_loop = AgentLoop.load(agent_state=agent, actor=actor)
result = await agent_loop.step(
messages,
max_steps=max_steps,
run_id=run_id,
use_assistant_message=use_assistant_message,
request_start_timestamp_ns=request_start_timestamp_ns,
include_return_message_types=include_return_message_types,
include_compaction_messages=include_compaction_messages,
)
else:
result = await server.send_message_to_agent(
agent_id=agent_id,
actor=actor,
input_messages=messages,
stream_steps=False,
stream_tokens=False,
metadata={"run_id": run_id},
# Support for AssistantMessage
use_assistant_message=use_assistant_message,
assistant_message_tool_name=assistant_message_tool_name,
assistant_message_tool_kwarg=assistant_message_tool_kwarg,
include_return_message_types=include_return_message_types,
)
runs_manager = RunManager() runs_manager = RunManager()
from letta.schemas.enums import RunStatus from letta.schemas.enums import RunStatus
from letta.schemas.letta_stop_reason import StopReasonType from letta.schemas.letta_stop_reason import StopReasonType
@@ -2170,33 +2107,10 @@ async def preview_model_request(
agent = await server.agent_manager.get_agent_by_id_async( agent = await server.agent_manager.get_agent_by_id_async(
agent_id, actor, include_relationships=["multi_agent_group", "memory", "sources"] agent_id, actor, include_relationships=["multi_agent_group", "memory", "sources"]
) )
agent_eligible = agent.multi_agent_group is None or agent.multi_agent_group.manager_type in ["sleeptime", "voice_sleeptime"] agent_loop = AgentLoop.load(agent_state=agent, actor=actor)
model_compatible = agent.llm_config.model_endpoint_type in [ return await agent_loop.build_request(
"anthropic", input_messages=request.messages,
"openai", )
"together",
"google_ai",
"google_vertex",
"bedrock",
"ollama",
"azure",
"xai",
"zai",
"groq",
"deepseek",
"chatgpt_oauth",
]
if agent_eligible and model_compatible:
agent_loop = AgentLoop.load(agent_state=agent, actor=actor)
return await agent_loop.build_request(
input_messages=request.messages,
)
else:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Payload inspection is not currently supported for this agent configuration.",
)
class CompactionRequest(BaseModel): class CompactionRequest(BaseModel):
@@ -2225,53 +2139,31 @@ async def summarize_messages(
actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id) actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id)
agent = await server.agent_manager.get_agent_by_id_async(agent_id, actor, include_relationships=["multi_agent_group"]) agent = await server.agent_manager.get_agent_by_id_async(agent_id, actor, include_relationships=["multi_agent_group"])
agent_eligible = agent.multi_agent_group is None or agent.multi_agent_group.manager_type in ["sleeptime", "voice_sleeptime"]
model_compatible = agent.llm_config.model_endpoint_type in [
"anthropic",
"openai",
"together",
"google_ai",
"google_vertex",
"bedrock",
"ollama",
"azure",
"xai",
"zai",
"groq",
"deepseek",
"chatgpt_oauth",
]
if agent_eligible and model_compatible: agent_loop = LettaAgentV3(agent_state=agent, actor=actor)
agent_loop = LettaAgentV3(agent_state=agent, actor=actor) in_context_messages = await server.message_manager.get_messages_by_ids_async(message_ids=agent.message_ids, actor=actor)
in_context_messages = await server.message_manager.get_messages_by_ids_async(message_ids=agent.message_ids, actor=actor) compaction_settings = request.compaction_settings if request else None
compaction_settings = request.compaction_settings if request else None num_messages_before = len(in_context_messages)
num_messages_before = len(in_context_messages) summary_message, messages, summary = await agent_loop.compact(
summary_message, messages, summary = await agent_loop.compact( messages=in_context_messages,
messages=in_context_messages, compaction_settings=compaction_settings,
compaction_settings=compaction_settings, use_summary_role=True,
use_summary_role=True, )
) num_messages_after = len(messages)
num_messages_after = len(messages)
# update the agent state # update the agent state
logger.info(f"Summarized {num_messages_before} messages to {num_messages_after}") logger.info(f"Summarized {num_messages_before} messages to {num_messages_after}")
if num_messages_before <= num_messages_after: if num_messages_before <= num_messages_after:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Summarization failed to reduce the number of messages. You may need to use a different CompactionSettings (e.g. using `all` mode).",
)
await agent_loop._checkpoint_messages(run_id=None, step_id=None, new_messages=[summary_message], in_context_messages=messages)
return CompactionResponse(
summary=summary,
num_messages_before=num_messages_before,
num_messages_after=num_messages_after,
)
else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_400_BAD_REQUEST,
detail="Summarization is not currently supported for this agent configuration. Please contact Letta support.", detail="Summarization failed to reduce the number of messages. You may need to use a different CompactionSettings (e.g. using `all` mode).",
) )
await agent_loop._checkpoint_messages(run_id=None, step_id=None, new_messages=[summary_message], in_context_messages=messages)
return CompactionResponse(
summary=summary,
num_messages_before=num_messages_before,
num_messages_after=num_messages_after,
)
class CaptureMessagesRequest(BaseModel): class CaptureMessagesRequest(BaseModel):

View File

@@ -470,29 +470,6 @@ async def compact_conversation(
# Get the agent state # Get the agent state
agent = await server.agent_manager.get_agent_by_id_async(conversation.agent_id, actor, include_relationships=["multi_agent_group"]) agent = await server.agent_manager.get_agent_by_id_async(conversation.agent_id, actor, include_relationships=["multi_agent_group"])
# Check eligibility
agent_eligible = agent.multi_agent_group is None or agent.multi_agent_group.manager_type in ["sleeptime", "voice_sleeptime"]
model_compatible = agent.llm_config.model_endpoint_type in [
"anthropic",
"openai",
"together",
"google_ai",
"google_vertex",
"bedrock",
"ollama",
"azure",
"xai",
"zai",
"groq",
"deepseek",
]
if not (agent_eligible and model_compatible):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Summarization is not currently supported for this agent configuration. Please contact Letta support.",
)
# Get in-context messages for this conversation # Get in-context messages for this conversation
in_context_messages = await conversation_manager.get_messages_for_conversation( in_context_messages = await conversation_manager.get_messages_for_conversation(
conversation_id=conversation_id, conversation_id=conversation_id,

View File

@@ -7,7 +7,6 @@ from pydantic import Field
from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG
from letta.schemas.group import Group, GroupBase, GroupCreate, GroupUpdate, ManagerType from letta.schemas.group import Group, GroupBase, GroupCreate, GroupUpdate, ManagerType
from letta.schemas.letta_message import LettaMessageUnion, LettaMessageUpdateUnion from letta.schemas.letta_message import LettaMessageUnion, LettaMessageUpdateUnion
from letta.schemas.letta_request import LettaRequest, LettaStreamingRequest
from letta.schemas.letta_response import LettaResponse from letta.schemas.letta_response import LettaResponse
from letta.schemas.message import BaseMessage from letta.schemas.message import BaseMessage
from letta.server.rest_api.dependencies import HeaderParams, get_headers, get_letta_server from letta.server.rest_api.dependencies import HeaderParams, get_headers, get_letta_server
@@ -128,77 +127,6 @@ async def delete_group(
return JSONResponse(status_code=status.HTTP_200_OK, content={"message": f"Group id={group_id} successfully deleted"}) return JSONResponse(status_code=status.HTTP_200_OK, content={"message": f"Group id={group_id} successfully deleted"})
@router.post(
"/{group_id}/messages",
response_model=LettaResponse,
operation_id="send_group_message",
deprecated=True,
)
async def send_group_message(
group_id: GroupId,
server: SyncServer = Depends(get_letta_server),
request: LettaRequest = Body(...),
headers: HeaderParams = Depends(get_headers),
):
"""
Process a user message and return the group's response.
This endpoint accepts a message from a user and processes it through through agents in the group based on the specified pattern
"""
actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id)
result = await server.send_group_message_to_agent(
group_id=group_id,
actor=actor,
input_messages=request.messages,
stream_steps=False,
stream_tokens=False,
# Support for AssistantMessage
use_assistant_message=request.use_assistant_message,
assistant_message_tool_name=request.assistant_message_tool_name,
assistant_message_tool_kwarg=request.assistant_message_tool_kwarg,
)
return result
@router.post(
"/{group_id}/messages/stream",
response_model=None,
operation_id="send_group_message_streaming",
deprecated=True,
responses={
200: {
"description": "Successful response",
"content": {
"text/event-stream": {"description": "Server-Sent Events stream"},
},
}
},
)
async def send_group_message_streaming(
group_id: GroupId,
server: SyncServer = Depends(get_letta_server),
request: LettaStreamingRequest = Body(...),
headers: HeaderParams = Depends(get_headers),
):
"""
Process a user message and return the group's responses.
This endpoint accepts a message from a user and processes it through agents in the group based on the specified pattern.
It will stream the steps of the response always, and stream the tokens if 'stream_tokens' is set to True.
"""
actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id)
result = await server.send_group_message_to_agent(
group_id=group_id,
actor=actor,
input_messages=request.messages,
stream_steps=True,
stream_tokens=request.stream_tokens,
# Support for AssistantMessage
use_assistant_message=request.use_assistant_message,
assistant_message_tool_name=request.assistant_message_tool_name,
assistant_message_tool_kwarg=request.assistant_message_tool_kwarg,
)
return result
GroupMessagesResponse = Annotated[ GroupMessagesResponse = Annotated[
List[LettaMessageUnion], Field(json_schema_extra={"type": "array", "items": {"$ref": "#/components/schemas/LettaMessageUnion"}}) List[LettaMessageUnion], Field(json_schema_extra={"type": "array", "items": {"$ref": "#/components/schemas/LettaMessageUnion"}})
] ]

View File

@@ -1754,244 +1754,3 @@ class SyncServer(object):
raise LettaInvalidArgumentError(f"Failed to write MCP config file {mcp_config_path}") raise LettaInvalidArgumentError(f"Failed to write MCP config file {mcp_config_path}")
return list(current_mcp_servers.values()) return list(current_mcp_servers.values())
@trace_method
async def send_message_to_agent(
self,
agent_id: str,
actor: User,
# role: MessageRole,
input_messages: List[MessageCreate],
stream_steps: bool,
stream_tokens: bool,
# related to whether or not we return `LettaMessage`s or `Message`s
chat_completion_mode: bool = False,
# Support for AssistantMessage
use_assistant_message: bool = True,
assistant_message_tool_name: str = constants.DEFAULT_MESSAGE_TOOL,
assistant_message_tool_kwarg: str = constants.DEFAULT_MESSAGE_TOOL_KWARG,
metadata: Optional[dict] = None,
request_start_timestamp_ns: Optional[int] = None,
include_return_message_types: Optional[List[MessageType]] = None,
) -> Union[StreamingResponse, LettaResponse]:
"""Split off into a separate function so that it can be imported in the /chat/completion proxy."""
# TODO: @charles is this the correct way to handle?
include_final_message = True
if not stream_steps and stream_tokens:
raise HTTPException(status_code=400, detail="stream_steps must be 'true' if stream_tokens is 'true'")
# For streaming response
try:
# TODO: move this logic into server.py
# Get the generator object off of the agent's streaming interface
# This will be attached to the POST SSE request used under-the-hood
letta_agent = self.load_agent(agent_id=agent_id, actor=actor)
# Disable token streaming if not OpenAI or Anthropic
# TODO: cleanup this logic
llm_config = letta_agent.agent_state.llm_config
# supports_token_streaming = ["openai", "anthropic", "xai", "deepseek"]
supports_token_streaming = ["openai", "anthropic", "deepseek", "chatgpt_oauth"] # TODO re-enable xAI once streaming is patched
if stream_tokens and (llm_config.model_endpoint_type not in supports_token_streaming):
logger.warning(
f"Token streaming is only supported for models with type {' or '.join(supports_token_streaming)} in the model_endpoint: agent has endpoint type {llm_config.model_endpoint_type} and {llm_config.model_endpoint}. Setting stream_tokens to False."
)
stream_tokens = False
# Create a new interface per request
letta_agent.interface = StreamingServerInterface(
# multi_step=True, # would we ever want to disable this?
use_assistant_message=use_assistant_message,
assistant_message_tool_name=assistant_message_tool_name,
assistant_message_tool_kwarg=assistant_message_tool_kwarg,
inner_thoughts_in_kwargs=(
llm_config.put_inner_thoughts_in_kwargs if llm_config.put_inner_thoughts_in_kwargs is not None else False
),
# inner_thoughts_kwarg=INNER_THOUGHTS_KWARG,
)
streaming_interface = letta_agent.interface
if not isinstance(streaming_interface, StreamingServerInterface):
raise LettaInvalidArgumentError(
f"Agent has wrong type of interface: {type(streaming_interface)}", argument_name="interface"
)
# Enable token-streaming within the request if desired
streaming_interface.streaming_mode = stream_tokens
# "chatcompletion mode" does some remapping and ignores inner thoughts
streaming_interface.streaming_chat_completion_mode = chat_completion_mode
# streaming_interface.allow_assistant_message = stream
# streaming_interface.function_call_legacy_mode = stream
# Allow AssistantMessage is desired by client
# streaming_interface.use_assistant_message = use_assistant_message
# streaming_interface.assistant_message_tool_name = assistant_message_tool_name
# streaming_interface.assistant_message_tool_kwarg = assistant_message_tool_kwarg
# Related to JSON buffer reader
# streaming_interface.inner_thoughts_in_kwargs = (
# llm_config.put_inner_thoughts_in_kwargs if llm_config.put_inner_thoughts_in_kwargs is not None else False
# )
# Offload the synchronous message_func to a separate thread
streaming_interface.stream_start()
task = safe_create_task(
asyncio.to_thread(
self.send_messages,
actor=actor,
agent_id=agent_id,
input_messages=input_messages,
interface=streaming_interface,
metadata=metadata,
),
label="send_messages_thread",
)
if stream_steps:
# return a stream
return StreamingResponse(
sse_async_generator(
streaming_interface.get_generator(),
usage_task=task,
finish_message=include_final_message,
request_start_timestamp_ns=request_start_timestamp_ns,
llm_config=llm_config,
),
media_type="text/event-stream",
)
else:
# buffer the stream, then return the list
generated_stream = []
async for message in streaming_interface.get_generator():
assert (
isinstance(message, LettaMessage)
or isinstance(message, LegacyLettaMessage)
or isinstance(message, MessageStreamStatus)
), type(message)
generated_stream.append(message)
if message == MessageStreamStatus.done:
break
# Get rid of the stream status messages
filtered_stream = [d for d in generated_stream if not isinstance(d, MessageStreamStatus)]
# Apply message type filtering if specified
if include_return_message_types is not None:
filtered_stream = [msg for msg in filtered_stream if msg.message_type in include_return_message_types]
usage = await task
# By default the stream will be messages of type LettaMessage or LettaLegacyMessage
# If we want to convert these to Message, we can use the attached IDs
# NOTE: we will need to de-duplicate the Messsage IDs though (since Assistant->Inner+Func_Call)
# TODO: eventually update the interface to use `Message` and `MessageChunk` (new) inside the deque instead
return LettaResponse(
messages=filtered_stream,
stop_reason=LettaStopReason(stop_reason=StopReasonType.end_turn.value),
usage=usage,
)
except HTTPException:
raise
except Exception as e:
logger.exception(f"Error sending message to agent: {e}")
raise HTTPException(status_code=500, detail=f"{e}")
@trace_method
async def send_group_message_to_agent(
self,
group_id: str,
actor: User,
input_messages: Union[List[Message], List[MessageCreate]],
stream_steps: bool,
stream_tokens: bool,
chat_completion_mode: bool = False,
# Support for AssistantMessage
use_assistant_message: bool = True,
assistant_message_tool_name: str = constants.DEFAULT_MESSAGE_TOOL,
assistant_message_tool_kwarg: str = constants.DEFAULT_MESSAGE_TOOL_KWARG,
metadata: Optional[dict] = None,
) -> Union[StreamingResponse, LettaResponse]:
include_final_message = True
if not stream_steps and stream_tokens:
raise LettaInvalidArgumentError("stream_steps must be 'true' if stream_tokens is 'true'", argument_name="stream_steps")
group = await self.group_manager.retrieve_group_async(group_id=group_id, actor=actor)
agent_state_id = group.manager_agent_id or (group.agent_ids[0] if len(group.agent_ids) > 0 else None)
agent_state = await self.agent_manager.get_agent_by_id_async(agent_id=agent_state_id, actor=actor) if agent_state_id else None
letta_multi_agent = load_multi_agent(group=group, agent_state=agent_state, actor=actor)
llm_config = letta_multi_agent.agent_state.llm_config
supports_token_streaming = ["openai", "anthropic", "deepseek", "chatgpt_oauth"]
if stream_tokens and (llm_config.model_endpoint_type not in supports_token_streaming):
logger.warning(
f"Token streaming is only supported for models with type {' or '.join(supports_token_streaming)} in the model_endpoint: agent has endpoint type {llm_config.model_endpoint_type} and {llm_config.model_endpoint}. Setting stream_tokens to False."
)
stream_tokens = False
# Create a new interface per request
letta_multi_agent.interface = StreamingServerInterface(
use_assistant_message=use_assistant_message,
assistant_message_tool_name=assistant_message_tool_name,
assistant_message_tool_kwarg=assistant_message_tool_kwarg,
inner_thoughts_in_kwargs=(
llm_config.put_inner_thoughts_in_kwargs if llm_config.put_inner_thoughts_in_kwargs is not None else False
),
)
streaming_interface = letta_multi_agent.interface
if not isinstance(streaming_interface, StreamingServerInterface):
raise LettaInvalidArgumentError(f"Agent has wrong type of interface: {type(streaming_interface)}", argument_name="interface")
streaming_interface.streaming_mode = stream_tokens
streaming_interface.streaming_chat_completion_mode = chat_completion_mode
if metadata and hasattr(streaming_interface, "metadata"):
streaming_interface.metadata = metadata
streaming_interface.stream_start()
task = safe_create_task(
asyncio.to_thread(
letta_multi_agent.step,
input_messages=input_messages,
chaining=self.chaining,
max_chaining_steps=self.max_chaining_steps,
),
label="multi_agent_step_thread",
)
if stream_steps:
# return a stream
return StreamingResponse(
sse_async_generator(
streaming_interface.get_generator(),
usage_task=task,
finish_message=include_final_message,
),
media_type="text/event-stream",
)
else:
# buffer the stream, then return the list
generated_stream = []
async for message in streaming_interface.get_generator():
assert (
isinstance(message, LettaMessage) or isinstance(message, LegacyLettaMessage) or isinstance(message, MessageStreamStatus)
), type(message)
generated_stream.append(message)
if message == MessageStreamStatus.done:
break
# Get rid of the stream status messages
filtered_stream = [d for d in generated_stream if not isinstance(d, MessageStreamStatus)]
usage = await task
# By default the stream will be messages of type LettaMessage or LettaLegacyMessage
# If we want to convert these to Message, we can use the attached IDs
# NOTE: we will need to de-duplicate the Messsage IDs though (since Assistant->Inner+Func_Call)
# TODO: eventually update the interface to use `Message` and `MessageChunk` (new) inside the deque instead
return LettaResponse(
messages=filtered_stream,
stop_reason=LettaStopReason(stop_reason=StopReasonType.end_turn.value),
usage=usage,
)

View File

@@ -111,8 +111,6 @@ class StreamingService:
# Create a copy of agent state with the overridden llm_config # Create a copy of agent state with the overridden llm_config
agent = agent.model_copy(update={"llm_config": override_llm_config}) agent = agent.model_copy(update={"llm_config": override_llm_config})
agent_eligible = self._is_agent_eligible(agent)
model_compatible = self._is_model_compatible(agent)
model_compatible_token_streaming = self._is_token_streaming_compatible(agent) model_compatible_token_streaming = self._is_token_streaming_compatible(agent)
# Attempt to acquire conversation lock if conversation_id is provided # Attempt to acquire conversation lock if conversation_id is provided
@@ -133,68 +131,40 @@ class StreamingService:
run = await self._create_run(agent_id, request, run_type, actor, conversation_id=conversation_id) run = await self._create_run(agent_id, request, run_type, actor, conversation_id=conversation_id)
await redis_client.set(f"{REDIS_RUN_ID_PREFIX}:{agent_id}", run.id if run else None) await redis_client.set(f"{REDIS_RUN_ID_PREFIX}:{agent_id}", run.id if run else None)
if agent_eligible and model_compatible: # use agent loop for streaming
# use agent loop for streaming agent_loop = AgentLoop.load(agent_state=agent, actor=actor)
agent_loop = AgentLoop.load(agent_state=agent, actor=actor)
# create the base stream with error handling # create the base stream with error handling
raw_stream = self._create_error_aware_stream( raw_stream = self._create_error_aware_stream(
agent_loop=agent_loop, agent_loop=agent_loop,
messages=request.messages, messages=request.messages,
max_steps=request.max_steps, max_steps=request.max_steps,
stream_tokens=request.stream_tokens and model_compatible_token_streaming, stream_tokens=request.stream_tokens and model_compatible_token_streaming,
run_id=run.id if run else None, run_id=run.id if run else None,
use_assistant_message=request.use_assistant_message, use_assistant_message=request.use_assistant_message,
request_start_timestamp_ns=request_start_timestamp_ns, request_start_timestamp_ns=request_start_timestamp_ns,
include_return_message_types=request.include_return_message_types, include_return_message_types=request.include_return_message_types,
actor=actor, actor=actor,
conversation_id=conversation_id, conversation_id=conversation_id,
client_tools=request.client_tools, client_tools=request.client_tools,
include_compaction_messages=request.include_compaction_messages, include_compaction_messages=request.include_compaction_messages,
) )
# handle background streaming if requested # handle background streaming if requested
if request.background and settings.track_agent_run: if request.background and settings.track_agent_run:
if isinstance(redis_client, NoopAsyncRedisClient): if isinstance(redis_client, NoopAsyncRedisClient):
raise LettaServiceUnavailableError( raise LettaServiceUnavailableError(
f"Background streaming requires Redis to be running. " f"Background streaming requires Redis to be running. "
f"Please ensure Redis is properly configured. " f"Please ensure Redis is properly configured. "
f"LETTA_REDIS_HOST: {settings.redis_host}, LETTA_REDIS_PORT: {settings.redis_port}", f"LETTA_REDIS_HOST: {settings.redis_host}, LETTA_REDIS_PORT: {settings.redis_port}",
service_name="redis", service_name="redis",
)
# Wrap the agent loop stream with cancellation awareness for background task
background_stream = raw_stream
if settings.enable_cancellation_aware_streaming and run:
background_stream = cancellation_aware_stream_wrapper(
stream_generator=raw_stream,
run_manager=self.runs_manager,
run_id=run.id,
actor=actor,
cancellation_event=get_cancellation_event_for_run(run.id),
)
safe_create_task(
create_background_stream_processor(
stream_generator=background_stream,
redis_client=redis_client,
run_id=run.id,
run_manager=self.server.run_manager,
actor=actor,
conversation_id=conversation_id,
),
label=f"background_stream_processor_{run.id}",
) )
raw_stream = redis_sse_stream_generator( # Wrap the agent loop stream with cancellation awareness for background task
redis_client=redis_client, background_stream = raw_stream
run_id=run.id, if settings.enable_cancellation_aware_streaming and run:
) background_stream = cancellation_aware_stream_wrapper(
# wrap client stream with cancellation awareness if enabled and tracking runs
stream = raw_stream
if settings.enable_cancellation_aware_streaming and settings.track_agent_run and run and not request.background:
stream = cancellation_aware_stream_wrapper(
stream_generator=raw_stream, stream_generator=raw_stream,
run_manager=self.runs_manager, run_manager=self.runs_manager,
run_id=run.id, run_id=run.id,
@@ -202,29 +172,43 @@ class StreamingService:
cancellation_event=get_cancellation_event_for_run(run.id), cancellation_event=get_cancellation_event_for_run(run.id),
) )
# conditionally wrap with keepalive based on request parameter safe_create_task(
if request.include_pings and settings.enable_keepalive: create_background_stream_processor(
stream = add_keepalive_to_stream(stream, keepalive_interval=settings.keepalive_interval, run_id=run.id) stream_generator=background_stream,
redis_client=redis_client,
run_id=run.id,
run_manager=self.server.run_manager,
actor=actor,
conversation_id=conversation_id,
),
label=f"background_stream_processor_{run.id}",
)
result = StreamingResponseWithStatusCode( raw_stream = redis_sse_stream_generator(
stream, redis_client=redis_client,
media_type="text/event-stream", run_id=run.id,
) )
else:
# fallback to non-agent-loop streaming # wrap client stream with cancellation awareness if enabled and tracking runs
result = await self.server.send_message_to_agent( stream = raw_stream
agent_id=agent_id, if settings.enable_cancellation_aware_streaming and settings.track_agent_run and run and not request.background:
stream = cancellation_aware_stream_wrapper(
stream_generator=raw_stream,
run_manager=self.runs_manager,
run_id=run.id,
actor=actor, actor=actor,
input_messages=request.messages, cancellation_event=get_cancellation_event_for_run(run.id),
stream_steps=True,
stream_tokens=request.stream_tokens,
use_assistant_message=request.use_assistant_message,
assistant_message_tool_name=request.assistant_message_tool_name,
assistant_message_tool_kwarg=request.assistant_message_tool_kwarg,
request_start_timestamp_ns=request_start_timestamp_ns,
include_return_message_types=request.include_return_message_types,
) )
# conditionally wrap with keepalive based on request parameter
if request.include_pings and settings.enable_keepalive:
stream = add_keepalive_to_stream(stream, keepalive_interval=settings.keepalive_interval, run_id=run.id)
result = StreamingResponseWithStatusCode(
stream,
media_type="text/event-stream",
)
# update run status to running before returning # update run status to running before returning
if settings.track_agent_run and run: if settings.track_agent_run and run:
# refetch run since it may have been updated by another service # refetch run since it may have been updated by another service
@@ -499,30 +483,6 @@ class StreamingService:
return error_aware_stream() return error_aware_stream()
def _is_agent_eligible(self, agent: AgentState) -> bool:
"""Check if agent is eligible for streaming."""
return agent.multi_agent_group is None or agent.multi_agent_group.manager_type in ["sleeptime", "voice_sleeptime"]
def _is_model_compatible(self, agent: AgentState) -> bool:
"""Check if agent's model is compatible with streaming."""
return agent.llm_config.model_endpoint_type in [
"anthropic",
"openai",
"together",
"google_ai",
"google_vertex",
"bedrock",
"ollama",
"azure",
"xai",
"zai",
"groq",
"deepseek",
"chatgpt_oauth",
"minimax",
"openrouter",
]
def _is_token_streaming_compatible(self, agent: AgentState) -> bool: def _is_token_streaming_compatible(self, agent: AgentState) -> bool:
"""Check if agent's model supports token-level streaming.""" """Check if agent's model supports token-level streaming."""
base_compatible = agent.llm_config.model_endpoint_type in [ base_compatible = agent.llm_config.model_endpoint_type in [

View File

@@ -69,12 +69,9 @@ def test_multi_agent_large(server, default_user, roll_dice_tool, num_workers):
actor=default_user, actor=default_user,
) )
manager_agent = server.load_agent(agent_id=manager_agent_state.id, actor=default_user)
# Create N worker agents # Create N worker agents
worker_agents = []
for idx in tqdm(range(num_workers)): for idx in tqdm(range(num_workers)):
worker_agent_state = server.create_agent( server.create_agent(
CreateAgent( CreateAgent(
name=f"worker-{idx}", name=f"worker-{idx}",
tool_ids=[roll_dice_tool.id], tool_ids=[roll_dice_tool.id],
@@ -86,13 +83,11 @@ def test_multi_agent_large(server, default_user, roll_dice_tool, num_workers):
), ),
actor=default_user, actor=default_user,
) )
worker_agent = server.load_agent(agent_id=worker_agent_state.id, actor=default_user)
worker_agents.append(worker_agent)
# Manager sends broadcast message # Manager sends broadcast message
broadcast_message = f"Send a message to all agents with tags {worker_tags} asking them to roll a dice for you!" broadcast_message = f"Send a message to all agents with tags {worker_tags} asking them to roll a dice for you!"
server.send_messages( server.send_messages(
actor=default_user, actor=default_user,
agent_id=manager_agent.agent_state.id, agent_id=manager_agent_state.id,
input_messages=[MessageCreate(role="user", content=broadcast_message)], input_messages=[MessageCreate(role="user", content=broadcast_message)],
) )

View File

@@ -1,20 +1,14 @@
import asyncio
import pytest import pytest
from letta.config import LettaConfig from letta.config import LettaConfig
from letta.schemas.agent import CreateAgent from letta.schemas.agent import CreateAgent
from letta.schemas.block import CreateBlock from letta.schemas.block import CreateBlock
from letta.schemas.group import ( from letta.schemas.group import (
DynamicManager,
DynamicManagerUpdate, DynamicManagerUpdate,
GroupCreate, GroupCreate,
GroupUpdate, GroupUpdate,
ManagerType, ManagerType,
RoundRobinManagerUpdate,
SupervisorManager,
) )
from letta.schemas.message import MessageCreate
from letta.server.server import SyncServer from letta.server.server import SyncServer
@@ -125,30 +119,6 @@ async def manager_agent(server, default_user):
yield agent_scooby yield agent_scooby
async def test_empty_group(server, default_user):
group = await server.group_manager.create_group_async(
group=GroupCreate(
description="This is a group chat between best friends all like to hang out together. In their free time they like to solve mysteries.",
agent_ids=[],
),
actor=default_user,
)
with pytest.raises(ValueError, match="Empty group"):
await server.send_group_message_to_agent(
group_id=group.id,
actor=default_user,
input_messages=[
MessageCreate(
role="user",
content="what is everyone up to for the holidays?",
),
],
stream_steps=False,
stream_tokens=False,
)
await server.group_manager.delete_group_async(group_id=group.id, actor=default_user)
async def test_modify_group_pattern(server, default_user, four_participant_agents, manager_agent): async def test_modify_group_pattern(server, default_user, four_participant_agents, manager_agent):
group = await server.group_manager.create_group_async( group = await server.group_manager.create_group_async(
group=GroupCreate( group=GroupCreate(
@@ -195,243 +165,3 @@ async def test_list_agent_groups(server, default_user, four_participant_agents):
await server.group_manager.delete_group_async(group_id=group_a.id, actor=default_user) await server.group_manager.delete_group_async(group_id=group_a.id, actor=default_user)
await server.group_manager.delete_group_async(group_id=group_b.id, actor=default_user) await server.group_manager.delete_group_async(group_id=group_b.id, actor=default_user)
async def test_round_robin(server, default_user, four_participant_agents):
description = (
"This is a group chat between best friends all like to hang out together. In their free time they like to solve mysteries."
)
group = await server.group_manager.create_group_async(
group=GroupCreate(
description=description,
agent_ids=[agent.id for agent in four_participant_agents],
),
actor=default_user,
)
# verify group creation
assert group.manager_type == ManagerType.round_robin
assert group.description == description
assert group.agent_ids == [agent.id for agent in four_participant_agents]
assert group.max_turns is None
assert group.manager_agent_id is None
assert group.termination_token is None
try:
server.group_manager.reset_messages(group_id=group.id, actor=default_user)
response = await server.send_group_message_to_agent(
group_id=group.id,
actor=default_user,
input_messages=[
MessageCreate(
role="user",
content="what is everyone up to for the holidays?",
),
],
stream_steps=False,
stream_tokens=False,
)
assert response.usage.step_count == len(group.agent_ids)
assert len(response.messages) == response.usage.step_count * 2
for i, message in enumerate(response.messages):
assert message.message_type == "reasoning_message" if i % 2 == 0 else "assistant_message"
assert message.name == four_participant_agents[i // 2].name
for agent_id in group.agent_ids:
agent_messages = await server.get_agent_recall(
user_id=default_user.id,
agent_id=agent_id,
group_id=group.id,
reverse=True,
return_message_object=False,
)
assert len(agent_messages) == len(group.agent_ids) + 2 # add one for user message, one for reasoning message
# TODO: filter this to return a clean conversation history
messages = server.group_manager.list_group_messages(
group_id=group.id,
actor=default_user,
)
assert len(messages) == (len(group.agent_ids) + 2) * len(group.agent_ids)
max_turns = 3
group = await server.group_manager.modify_group_async(
group_id=group.id,
group_update=GroupUpdate(
agent_ids=[agent.id for agent in four_participant_agents][::-1],
manager_config=RoundRobinManagerUpdate(
max_turns=max_turns,
),
),
actor=default_user,
)
assert group.manager_type == ManagerType.round_robin
assert group.description == description
assert group.agent_ids == [agent.id for agent in four_participant_agents][::-1]
assert group.max_turns == max_turns
assert group.manager_agent_id is None
assert group.termination_token is None
server.group_manager.reset_messages(group_id=group.id, actor=default_user)
response = await server.send_group_message_to_agent(
group_id=group.id,
actor=default_user,
input_messages=[
MessageCreate(
role="user",
content="when should we plan our next adventure?",
),
],
stream_steps=False,
stream_tokens=False,
)
assert response.usage.step_count == max_turns
assert len(response.messages) == max_turns * 2
for i, message in enumerate(response.messages):
assert message.message_type == "reasoning_message" if i % 2 == 0 else "assistant_message"
assert message.name == four_participant_agents[::-1][i // 2].name
for i in range(len(group.agent_ids)):
agent_messages = await server.get_agent_recall(
user_id=default_user.id,
agent_id=group.agent_ids[i],
group_id=group.id,
reverse=True,
return_message_object=False,
)
expected_message_count = max_turns + 1 if i >= max_turns else max_turns + 2
assert len(agent_messages) == expected_message_count
finally:
await server.group_manager.delete_group_async(group_id=group.id, actor=default_user)
async def test_supervisor(server, default_user, four_participant_agents):
agent_scrappy = await server.create_agent_async(
request=CreateAgent(
name="shaggy",
memory_blocks=[
CreateBlock(
label="persona",
value="You are a puppy operations agent for Letta and you help run multi-agent group chats. Your role is to supervise the group, sending messages and aggregating the responses.",
),
CreateBlock(
label="human",
value="",
),
],
model="openai/gpt-4o-mini",
embedding="openai/text-embedding-3-small",
),
actor=default_user,
)
group = await server.group_manager.create_group_async(
group=GroupCreate(
description="This is a group chat between best friends all like to hang out together. In their free time they like to solve mysteries.",
agent_ids=[agent.id for agent in four_participant_agents],
manager_config=SupervisorManager(
manager_agent_id=agent_scrappy.id,
),
),
actor=default_user,
)
try:
response = await server.send_group_message_to_agent(
group_id=group.id,
actor=default_user,
input_messages=[
MessageCreate(
role="user",
content="ask everyone what they like to do for fun and then come up with an activity for everyone to do together.",
),
],
stream_steps=False,
stream_tokens=False,
)
assert response.usage.step_count == 2
assert len(response.messages) == 5
# verify tool call
assert response.messages[0].message_type == "reasoning_message"
assert (
response.messages[1].message_type == "tool_call_message"
and response.messages[1].tool_call.name == "send_message_to_all_agents_in_group"
)
assert response.messages[2].message_type == "tool_return_message" and len(eval(response.messages[2].tool_return)) == len(
four_participant_agents
)
assert response.messages[3].message_type == "reasoning_message"
assert response.messages[4].message_type == "assistant_message"
finally:
await server.group_manager.delete_group_async(group_id=group.id, actor=default_user)
server.agent_manager.delete_agent(agent_id=agent_scrappy.id, actor=default_user)
@pytest.mark.flaky(max_runs=2)
async def test_dynamic_group_chat(server, default_user, manager_agent, four_participant_agents):
description = (
"This is a group chat between best friends all like to hang out together. In their free time they like to solve mysteries."
)
# error on duplicate agent in participant list
with pytest.raises(ValueError, match="Duplicate agent ids"):
await server.group_manager.create_group_async(
group=GroupCreate(
description=description,
agent_ids=[agent.id for agent in four_participant_agents] + [four_participant_agents[0].id],
manager_config=DynamicManager(
manager_agent_id=manager_agent.id,
),
),
actor=default_user,
)
# error on duplicate agent names
duplicate_agent_shaggy = server.create_agent(
request=CreateAgent(
name="shaggy",
model="openai/gpt-4o-mini",
embedding="openai/text-embedding-3-small",
),
actor=default_user,
)
with pytest.raises(ValueError, match="Duplicate agent names"):
await server.group_manager.create_group_async(
group=GroupCreate(
description=description,
agent_ids=[agent.id for agent in four_participant_agents] + [duplicate_agent_shaggy.id],
manager_config=DynamicManager(
manager_agent_id=manager_agent.id,
),
),
actor=default_user,
)
server.agent_manager.delete_agent(duplicate_agent_shaggy.id, actor=default_user)
group = await server.group_manager.create_group_async(
group=GroupCreate(
description=description,
agent_ids=[agent.id for agent in four_participant_agents],
manager_config=DynamicManager(
manager_agent_id=manager_agent.id,
),
),
actor=default_user,
)
try:
response = await server.send_group_message_to_agent(
group_id=group.id,
actor=default_user,
input_messages=[
MessageCreate(role="user", content="what is everyone up to for the holidays?"),
],
stream_steps=False,
stream_tokens=False,
)
assert response.usage.step_count == len(four_participant_agents) * 2
assert len(response.messages) == response.usage.step_count * 2
finally:
await server.group_manager.delete_group_async(group_id=group.id, actor=default_user)