feat: add agent_id to search results (#6867)

This commit is contained in:
Sarah Wooders
2025-12-14 16:54:00 -08:00
committed by Caren Thomas
parent 4b9485a484
commit a721a00899
6 changed files with 354 additions and 46 deletions

View File

@@ -15632,53 +15632,25 @@
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/SystemMessage"
"$ref": "#/components/schemas/SystemMessageListResult"
},
{
"$ref": "#/components/schemas/UserMessage"
"$ref": "#/components/schemas/UserMessageListResult"
},
{
"$ref": "#/components/schemas/ReasoningMessage"
"$ref": "#/components/schemas/ReasoningMessageListResult"
},
{
"$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"
"$ref": "#/components/schemas/AssistantMessageListResult"
}
],
"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": "#/components/schemas/SummaryMessage",
"event": "#/components/schemas/EventMessage"
"system_message": "#/components/schemas/SystemMessageListResult",
"user_message": "#/components/schemas/UserMessageListResult",
"reasoning_message": "#/components/schemas/ReasoningMessageListResult",
"assistant_message": "#/components/schemas/AssistantMessageListResult"
}
}
},
@@ -21957,6 +21929,53 @@
"title": "AssistantMessage",
"description": "A message sent by the LLM in response to user input. Used in the LLM context.\n\nArgs:\n id (str): The ID of the message\n date (datetime): The date the message was created in ISO format\n name (Optional[str]): The name of the sender of the message\n content (Union[str, List[LettaAssistantMessageContentUnion]]): The message content sent by the agent (can be a string or an array of content parts)"
},
"AssistantMessageListResult": {
"properties": {
"message_type": {
"type": "string",
"const": "assistant_message",
"title": "Message Type",
"default": "assistant_message"
},
"content": {
"anyOf": [
{
"items": {
"$ref": "#/components/schemas/LettaAssistantMessageContentUnion"
},
"type": "array"
},
{
"type": "string"
}
],
"title": "Content",
"description": "The message content sent by the assistant (can be a string or an array of content parts)"
},
"agent_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Agent Id",
"description": "The unique identifier of the agent that owns the message."
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At",
"description": "The time the message was created in ISO format."
}
},
"type": "object",
"required": ["content", "created_at"],
"title": "AssistantMessageListResult",
"description": "Assistant message list result with agent context.\n\nShape is identical to UpdateAssistantMessage but includes the owning agent_id."
},
"Audio": {
"properties": {
"id": {
@@ -34195,6 +34214,42 @@
"title": "ReasoningMessage",
"description": "Representation of an agent's internal reasoning.\n\nArgs:\n id (str): The ID of the message\n date (datetime): The date the message was created in ISO format\n name (Optional[str]): The name of the sender of the message\n source (Literal[\"reasoner_model\", \"non_reasoner_model\"]): Whether the reasoning\n content was generated natively by a reasoner model or derived via prompting\n reasoning (str): The internal reasoning of the agent\n signature (Optional[str]): The model-generated signature of the reasoning step"
},
"ReasoningMessageListResult": {
"properties": {
"reasoning": {
"type": "string",
"title": "Reasoning"
},
"message_type": {
"type": "string",
"const": "reasoning_message",
"title": "Message Type",
"default": "reasoning_message"
},
"agent_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Agent Id",
"description": "The unique identifier of the agent that owns the message."
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At",
"description": "The time the message was created in ISO format."
}
},
"type": "object",
"required": ["reasoning", "created_at"],
"title": "ReasoningMessageListResult",
"description": "Reasoning message list result with agent context.\n\nShape is identical to UpdateReasoningMessage but includes the owning agent_id."
},
"RedactedReasoningContent": {
"properties": {
"type": {
@@ -36652,6 +36707,43 @@
"title": "SystemMessage",
"description": "A message generated by the system. Never streamed back on a response, only used for cursor pagination.\n\nArgs:\n id (str): The ID of the message\n date (datetime): The date the message was created in ISO format\n name (Optional[str]): The name of the sender of the message\n content (str): The message content sent by the system"
},
"SystemMessageListResult": {
"properties": {
"message_type": {
"type": "string",
"const": "system_message",
"title": "Message Type",
"default": "system_message"
},
"content": {
"type": "string",
"title": "Content",
"description": "The message content sent by the system (can be a string or an array of multi-modal content parts)"
},
"agent_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Agent Id",
"description": "The unique identifier of the agent that owns the message."
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At",
"description": "The time the message was created in ISO format."
}
},
"type": "object",
"required": ["content", "created_at"],
"title": "SystemMessageListResult",
"description": "System message list result with agent context.\n\nShape is identical to UpdateSystemMessage but includes the owning agent_id."
},
"TagSchema": {
"properties": {
"tag": {
@@ -39276,6 +39368,53 @@
"title": "UserMessage",
"description": "A message sent by the user. Never streamed back on a response, only used for cursor pagination.\n\nArgs:\n id (str): The ID of the message\n date (datetime): The date the message was created in ISO format\n name (Optional[str]): The name of the sender of the message\n content (Union[str, List[LettaUserMessageContentUnion]]): The message content sent by the user (can be a string or an array of multi-modal content parts)"
},
"UserMessageListResult": {
"properties": {
"message_type": {
"type": "string",
"const": "user_message",
"title": "Message Type",
"default": "user_message"
},
"content": {
"anyOf": [
{
"items": {
"$ref": "#/components/schemas/LettaUserMessageContentUnion"
},
"type": "array"
},
{
"type": "string"
}
],
"title": "Content",
"description": "The message content sent by the user (can be a string or an array of multi-modal content parts)"
},
"agent_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Agent Id",
"description": "The unique identifier of the agent that owns the message."
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At",
"description": "The time the message was created in ISO format."
}
},
"type": "object",
"required": ["content", "created_at"],
"title": "UserMessageListResult",
"description": "User message list result with agent context.\n\nShape is identical to UpdateUserMessage but includes the owning agent_id."
},
"UserUpdate": {
"properties": {
"id": {

View File

@@ -555,6 +555,78 @@ LettaMessageUpdateUnion = Annotated[
]
# ------------------------------
# Message Search Result Schemas
# ------------------------------
class SystemMessageListResult(UpdateSystemMessage):
"""System message list result with agent context.
Shape is identical to UpdateSystemMessage but includes the owning agent_id.
"""
agent_id: str | None = Field(
default=None,
description="The unique identifier of the agent that owns the message.",
)
created_at: datetime = Field(..., description="The time the message was created in ISO format.")
class UserMessageListResult(UpdateUserMessage):
"""User message list result with agent context.
Shape is identical to UpdateUserMessage but includes the owning agent_id.
"""
agent_id: str | None = Field(
default=None,
description="The unique identifier of the agent that owns the message.",
)
created_at: datetime = Field(..., description="The time the message was created in ISO format.")
class ReasoningMessageListResult(UpdateReasoningMessage):
"""Reasoning message list result with agent context.
Shape is identical to UpdateReasoningMessage but includes the owning agent_id.
"""
agent_id: str | None = Field(
default=None,
description="The unique identifier of the agent that owns the message.",
)
created_at: datetime = Field(..., description="The time the message was created in ISO format.")
class AssistantMessageListResult(UpdateAssistantMessage):
"""Assistant message list result with agent context.
Shape is identical to UpdateAssistantMessage but includes the owning agent_id.
"""
agent_id: str | None = Field(
default=None,
description="The unique identifier of the agent that owns the message.",
)
created_at: datetime = Field(..., description="The time the message was created in ISO format.")
LettaMessageSearchResult = Annotated[
Union[
SystemMessageListResult,
UserMessageListResult,
ReasoningMessageListResult,
AssistantMessageListResult,
],
Field(discriminator="message_type"),
]
# --------------------------
# Deprecated Message Schemas
# --------------------------

View File

@@ -29,17 +29,22 @@ from letta.schemas.letta_message import (
ApprovalResponseMessage,
ApprovalReturn,
AssistantMessage,
AssistantMessageListResult,
HiddenReasoningMessage,
LettaMessage,
LettaMessageReturnUnion,
LettaMessageSearchResult,
MessageType,
ReasoningMessage,
ReasoningMessageListResult,
SystemMessage,
SystemMessageListResult,
ToolCall,
ToolCallMessage,
ToolReturn as LettaToolReturn,
ToolReturnMessage,
UserMessage,
UserMessageListResult,
)
from letta.schemas.letta_message_content import (
ImageContent,
@@ -319,6 +324,80 @@ class Message(BaseMessage):
)
]
@staticmethod
@trace_method
def to_letta_search_results_from_list(
search_results: List["MessageSearchResult"],
use_assistant_message: bool = True,
assistant_message_tool_name: str = DEFAULT_MESSAGE_TOOL,
assistant_message_tool_kwarg: str = DEFAULT_MESSAGE_TOOL_KWARG,
reverse: bool = True,
include_err: Optional[bool] = None,
text_is_assistant_message: bool = False,
) -> List[LettaMessageSearchResult]:
"""Convert MessageSearchResult objects into LettaMessageSearchResult objects.
This mirrors the behavior of to_letta_messages_from_list, but preserves the
originating Message.agent_id on each search result variant.
"""
letta_search_results: List[LettaMessageSearchResult] = []
for result in search_results:
message = result.message
# Convert the underlying Message into LettaMessage variants
letta_messages = message.to_letta_messages(
use_assistant_message=use_assistant_message,
assistant_message_tool_name=assistant_message_tool_name,
assistant_message_tool_kwarg=assistant_message_tool_kwarg,
reverse=reverse,
include_err=include_err,
text_is_assistant_message=text_is_assistant_message,
)
for lm in letta_messages:
if isinstance(lm, SystemMessage):
letta_search_results.append(
SystemMessageListResult(
message_type=lm.message_type,
content=lm.content,
agent_id=message.agent_id,
created_at=message.created_at,
)
)
elif isinstance(lm, UserMessage):
letta_search_results.append(
UserMessageListResult(
message_type=lm.message_type,
content=lm.content,
agent_id=message.agent_id,
created_at=message.created_at,
)
)
elif isinstance(lm, ReasoningMessage):
letta_search_results.append(
ReasoningMessageListResult(
message_type=lm.message_type,
reasoning=lm.reasoning,
agent_id=message.agent_id,
created_at=message.created_at,
)
)
elif isinstance(lm, AssistantMessage):
letta_search_results.append(
AssistantMessageListResult(
message_type=lm.message_type,
content=lm.content,
agent_id=message.agent_id,
created_at=message.created_at,
)
)
# Other LettaMessage variants (tool, approval, etc.) are not part of
# LettaMessageSearchResult and are intentionally skipped here.
return letta_search_results
def to_letta_messages(
self,
use_assistant_message: bool = False,

View File

@@ -8,7 +8,7 @@ from letta.agents.letta_agent_batch import LettaAgentBatch
from letta.errors import LettaInvalidArgumentError
from letta.log import get_logger
from letta.schemas.job import BatchJob, JobStatus, JobType, JobUpdate
from letta.schemas.letta_message import LettaMessageUnion
from letta.schemas.letta_message import LettaMessageSearchResult, LettaMessageUnion
from letta.schemas.letta_request import CreateBatch
from letta.schemas.letta_response import LettaBatchMessages
from letta.schemas.message import Message, MessageSearchRequest, MessageSearchResult, SearchAllMessagesRequest
@@ -55,7 +55,7 @@ async def list_all_messages(
)
@router.post("/search", response_model=List[LettaMessageUnion], operation_id="search_all_messages")
@router.post("/search", response_model=List[LettaMessageSearchResult], operation_id="search_all_messages")
async def search_all_messages(
request: SearchAllMessagesRequest = Body(...),
server: SyncServer = Depends(get_letta_server),
@@ -77,7 +77,7 @@ async def search_all_messages(
start_date=request.start_date,
end_date=request.end_date,
)
return Message.to_letta_messages_from_list(messages=[result.message for result in results], text_is_assistant_message=True)
return Message.to_letta_search_results_from_list(search_results=results, text_is_assistant_message=True)
@router.post(

View File

@@ -15,7 +15,6 @@ from letta_client import Letta
from letta_client.types import CreateBlockParam, MessageCreateParam
from letta.config import LettaConfig
from letta.schemas.message import MessageSearchResult
from letta.schemas.tool import ToolSearchResult
from letta.server.rest_api.routers.v1.passages import PassageSearchResult
from letta.server.server import SyncServer
@@ -387,10 +386,12 @@ def test_message_search_basic(client: Letta, enable_message_embedding):
time.sleep(10)
# Test FTS search for messages
# Note: The endpoint returns LettaMessageUnion, not MessageSearchResult
# Note: The endpoint returns LettaMessageSearchResult (API schema)
# and we treat the response as generic dicts here to avoid tight
# coupling to internal server-side models.
results = client.post(
"/v1/messages/search",
cast_to=list[MessageSearchResult],
cast_to=list[dict[str, Any]],
body={
"query": "capital Saudi Arabia",
"search_mode": "fts",
@@ -403,6 +404,17 @@ def test_message_search_basic(client: Letta, enable_message_embedding):
print(f"First result type: {type(results[0])}")
print(f"First result keys: {results[0].keys() if isinstance(results[0], dict) else 'N/A'}")
for result in results:
assert "agent_id" in result, "Result should have agent_id field"
# created_at should always be present and parseable
assert "created_at" in result, "Result should have created_at field"
assert result["created_at"], "created_at should be set"
created_at = result["created_at"]
if isinstance(created_at, str):
# Handle both "+00:00" and "Z" suffixes
datetime.fromisoformat(created_at.replace("Z", "+00:00"))
assert len(results) > 0, f"Should find at least one message. Got {len(results)} results."
finally:

View File

@@ -58,8 +58,8 @@ def agent(client: Letta):
agent_state = client.agents.create(
name="test_client",
memory_blocks=[{"label": "human", "value": ""}, {"label": "persona", "value": ""}],
model="letta/letta-free",
embedding="letta/letta-free",
model="anthropic/claude-haiku-4-5-20251001",
embedding="openai/text-embedding-3-small",
)
yield agent_state
@@ -74,8 +74,8 @@ def search_agent_one(client: Letta):
agent_state = client.agents.create(
name="Search Agent One",
memory_blocks=[{"label": "human", "value": ""}, {"label": "persona", "value": ""}],
model="letta/letta-free",
embedding="letta/letta-free",
model="anthropic/claude-haiku-4-5-20251001",
embedding="openai/text-embedding-3-small",
)
yield agent_state
@@ -460,6 +460,12 @@ def test_messages(client: Letta, agent: AgentState):
messages_response = client.agents.messages.list(agent_id=agent.id, limit=1).items
assert len(messages_response) > 0, "Retrieving messages failed"
search_response = list(client.messages.search(query="test"))
assert len(search_response) > 0, "Searching messages failed"
for result in search_response:
assert result.agent_id == agent.id
assert result.created_at
# TODO: Add back when new agent loop hits
# @pytest.mark.asyncio