feat: add input option to send message route [LET-4540] (#5938)

---------

Co-authored-by: Ari Webb <ari@letta.com>
This commit is contained in:
Ari Webb
2025-11-04 12:27:01 -08:00
committed by Caren Thomas
parent c92e1d56bb
commit ed99d7eb2b
4 changed files with 457 additions and 49 deletions

View File

@@ -27415,20 +27415,83 @@
"LettaAsyncRequest": {
"properties": {
"messages": {
"items": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageCreate"
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageCreate"
},
{
"$ref": "#/components/schemas/ApprovalCreate"
}
]
},
{
"$ref": "#/components/schemas/ApprovalCreate"
}
]
},
"type": "array",
"type": "array"
},
{
"type": "null"
}
],
"title": "Messages",
"description": "The messages to be sent to the agent."
},
"input": {
"anyOf": [
{
"type": "string"
},
{
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/TextContent"
},
{
"$ref": "#/components/schemas/ImageContent"
},
{
"$ref": "#/components/schemas/ToolCallContent"
},
{
"$ref": "#/components/schemas/ToolReturnContent"
},
{
"$ref": "#/components/schemas/ReasoningContent"
},
{
"$ref": "#/components/schemas/RedactedReasoningContent"
},
{
"$ref": "#/components/schemas/OmittedReasoningContent"
},
{
"$ref": "#/components/schemas/SummarizedReasoningContent"
}
],
"discriminator": {
"propertyName": "type",
"mapping": {
"image": "#/components/schemas/ImageContent",
"omitted_reasoning": "#/components/schemas/OmittedReasoningContent",
"reasoning": "#/components/schemas/ReasoningContent",
"redacted_reasoning": "#/components/schemas/RedactedReasoningContent",
"summarized_reasoning": "#/components/schemas/SummarizedReasoningContent",
"text": "#/components/schemas/TextContent",
"tool_call": "#/components/schemas/ToolCallContent",
"tool_return": "#/components/schemas/ToolReturnContent"
}
}
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Input",
"description": "Syntactic sugar for a single user message. Equivalent to messages=[{'role': 'user', 'content': input}]."
},
"max_steps": {
"type": "integer",
"title": "Max Steps",
@@ -27492,7 +27555,6 @@
}
},
"type": "object",
"required": ["messages"],
"title": "LettaAsyncRequest"
},
"LettaBatchMessages": {
@@ -27512,20 +27574,83 @@
"LettaBatchRequest": {
"properties": {
"messages": {
"items": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageCreate"
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageCreate"
},
{
"$ref": "#/components/schemas/ApprovalCreate"
}
]
},
{
"$ref": "#/components/schemas/ApprovalCreate"
}
]
},
"type": "array",
"type": "array"
},
{
"type": "null"
}
],
"title": "Messages",
"description": "The messages to be sent to the agent."
},
"input": {
"anyOf": [
{
"type": "string"
},
{
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/TextContent"
},
{
"$ref": "#/components/schemas/ImageContent"
},
{
"$ref": "#/components/schemas/ToolCallContent"
},
{
"$ref": "#/components/schemas/ToolReturnContent"
},
{
"$ref": "#/components/schemas/ReasoningContent"
},
{
"$ref": "#/components/schemas/RedactedReasoningContent"
},
{
"$ref": "#/components/schemas/OmittedReasoningContent"
},
{
"$ref": "#/components/schemas/SummarizedReasoningContent"
}
],
"discriminator": {
"propertyName": "type",
"mapping": {
"image": "#/components/schemas/ImageContent",
"omitted_reasoning": "#/components/schemas/OmittedReasoningContent",
"reasoning": "#/components/schemas/ReasoningContent",
"redacted_reasoning": "#/components/schemas/RedactedReasoningContent",
"summarized_reasoning": "#/components/schemas/SummarizedReasoningContent",
"text": "#/components/schemas/TextContent",
"tool_call": "#/components/schemas/ToolCallContent",
"tool_return": "#/components/schemas/ToolReturnContent"
}
}
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Input",
"description": "Syntactic sugar for a single user message. Equivalent to messages=[{'role': 'user', 'content': input}]."
},
"max_steps": {
"type": "integer",
"title": "Max Steps",
@@ -27582,7 +27707,7 @@
}
},
"type": "object",
"required": ["messages", "agent_id"],
"required": ["agent_id"],
"title": "LettaBatchRequest"
},
"LettaImage": {
@@ -27658,20 +27783,83 @@
"LettaRequest": {
"properties": {
"messages": {
"items": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageCreate"
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageCreate"
},
{
"$ref": "#/components/schemas/ApprovalCreate"
}
]
},
{
"$ref": "#/components/schemas/ApprovalCreate"
}
]
},
"type": "array",
"type": "array"
},
{
"type": "null"
}
],
"title": "Messages",
"description": "The messages to be sent to the agent."
},
"input": {
"anyOf": [
{
"type": "string"
},
{
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/TextContent"
},
{
"$ref": "#/components/schemas/ImageContent"
},
{
"$ref": "#/components/schemas/ToolCallContent"
},
{
"$ref": "#/components/schemas/ToolReturnContent"
},
{
"$ref": "#/components/schemas/ReasoningContent"
},
{
"$ref": "#/components/schemas/RedactedReasoningContent"
},
{
"$ref": "#/components/schemas/OmittedReasoningContent"
},
{
"$ref": "#/components/schemas/SummarizedReasoningContent"
}
],
"discriminator": {
"propertyName": "type",
"mapping": {
"image": "#/components/schemas/ImageContent",
"omitted_reasoning": "#/components/schemas/OmittedReasoningContent",
"reasoning": "#/components/schemas/ReasoningContent",
"redacted_reasoning": "#/components/schemas/RedactedReasoningContent",
"summarized_reasoning": "#/components/schemas/SummarizedReasoningContent",
"text": "#/components/schemas/TextContent",
"tool_call": "#/components/schemas/ToolCallContent",
"tool_return": "#/components/schemas/ToolReturnContent"
}
}
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Input",
"description": "Syntactic sugar for a single user message. Equivalent to messages=[{'role': 'user', 'content': input}]."
},
"max_steps": {
"type": "integer",
"title": "Max Steps",
@@ -27723,7 +27911,6 @@
}
},
"type": "object",
"required": ["messages"],
"title": "LettaRequest"
},
"LettaRequestConfig": {
@@ -27810,20 +27997,83 @@
"LettaStreamingRequest": {
"properties": {
"messages": {
"items": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageCreate"
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageCreate"
},
{
"$ref": "#/components/schemas/ApprovalCreate"
}
]
},
{
"$ref": "#/components/schemas/ApprovalCreate"
}
]
},
"type": "array",
"type": "array"
},
{
"type": "null"
}
],
"title": "Messages",
"description": "The messages to be sent to the agent."
},
"input": {
"anyOf": [
{
"type": "string"
},
{
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/TextContent"
},
{
"$ref": "#/components/schemas/ImageContent"
},
{
"$ref": "#/components/schemas/ToolCallContent"
},
{
"$ref": "#/components/schemas/ToolReturnContent"
},
{
"$ref": "#/components/schemas/ReasoningContent"
},
{
"$ref": "#/components/schemas/RedactedReasoningContent"
},
{
"$ref": "#/components/schemas/OmittedReasoningContent"
},
{
"$ref": "#/components/schemas/SummarizedReasoningContent"
}
],
"discriminator": {
"propertyName": "type",
"mapping": {
"image": "#/components/schemas/ImageContent",
"omitted_reasoning": "#/components/schemas/OmittedReasoningContent",
"reasoning": "#/components/schemas/ReasoningContent",
"redacted_reasoning": "#/components/schemas/RedactedReasoningContent",
"summarized_reasoning": "#/components/schemas/SummarizedReasoningContent",
"text": "#/components/schemas/TextContent",
"tool_call": "#/components/schemas/ToolCallContent",
"tool_return": "#/components/schemas/ToolReturnContent"
}
}
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Input",
"description": "Syntactic sugar for a single user message. Equivalent to messages=[{'role': 'user', 'content': input}]."
},
"max_steps": {
"type": "integer",
"title": "Max Steps",
@@ -27893,7 +28143,6 @@
}
},
"type": "object",
"required": ["messages"],
"title": "LettaStreamingRequest"
},
"LettaStreamingResponse": {

View File

@@ -1,14 +1,18 @@
from typing import List, Optional
from typing import List, Optional, Union
from pydantic import BaseModel, Field, HttpUrl, field_validator
from pydantic import BaseModel, Field, HttpUrl, field_validator, model_validator
from letta.constants import DEFAULT_MAX_STEPS, DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG
from letta.schemas.letta_message import MessageType
from letta.schemas.message import MessageCreateUnion
from letta.schemas.letta_message_content import LettaMessageContentUnion
from letta.schemas.message import MessageCreate, MessageCreateUnion, MessageRole
class LettaRequest(BaseModel):
messages: List[MessageCreateUnion] = Field(..., description="The messages to be sent to the agent.")
messages: Optional[List[MessageCreateUnion]] = Field(None, description="The messages to be sent to the agent.")
input: Optional[Union[str, List[LettaMessageContentUnion]]] = Field(
None, description="Syntactic sugar for a single user message. Equivalent to messages=[{'role': 'user', 'content': input}]."
)
max_steps: int = Field(
default=DEFAULT_MAX_STEPS,
description="Maximum number of steps the agent should take to process the request.",
@@ -57,6 +61,22 @@ class LettaRequest(BaseModel):
item["type"] = "message"
return v
@model_validator(mode="after")
def validate_input_or_messages(self):
"""Ensure exactly one of input or messages is set, and convert input to messages if needed"""
if self.input is not None and self.messages is not None:
raise ValueError("Cannot specify both 'input' and 'messages'. Use one or the other.")
if self.input is None and self.messages is None:
raise ValueError("Must specify either 'input' or 'messages'.")
# Convert input to messages format
# input can be either a string or List[LettaMessageContentUnion]
if self.input is not None:
# Both str and List[LettaMessageContentUnion] are valid content types for MessageCreate
self.messages = [MessageCreate(role=MessageRole.user, content=self.input)]
return self
class LettaStreamingRequest(LettaRequest):
stream_tokens: bool = Field(

View File

@@ -1293,7 +1293,8 @@ async def send_message(
Process a user message and return the agent's response.
This endpoint accepts a message from a user and processes it through the agent.
"""
if len(request.messages) == 0:
# After validation, messages should always be set (converted from input if needed)
if not request.messages or len(request.messages) == 0:
raise ValueError("Messages must not be empty")
request_start_timestamp_ns = get_utc_timestamp_ns()
MetricRegistry().user_message_counter.add(1, get_ctx_attributes())

View File

@@ -2334,3 +2334,141 @@ def test_inner_thoughts_toggle_interleaved(
# # Google uses 'contents' instead of 'messages'
# contents = response.get("contents", response.get("messages", []))
# validate_google_format_scrubbing(contents)
# ============================
# Input Parameter Tests
# ============================
@pytest.mark.parametrize(
"llm_config",
TESTED_LLM_CONFIGS,
ids=[c.model for c in TESTED_LLM_CONFIGS],
)
def test_input_parameter_basic(
disable_e2b_api_key: Any,
client: Letta,
agent_state: AgentState,
llm_config: LLMConfig,
) -> None:
"""
Tests sending a message using the input parameter instead of messages.
Verifies that input is properly converted to a user message.
"""
last_message_page = client.agents.messages.list(agent_id=agent_state.id, limit=1)
last_message = last_message_page.items[0] if last_message_page.items else None
agent_state = client.agents.modify(agent_id=agent_state.id, llm_config=llm_config)
# Use input parameter instead of messages
response = client.agents.messages.send(
agent_id=agent_state.id,
input=f"This is an automated test message. Call the send_message tool with the message '{USER_MESSAGE_RESPONSE}'.",
)
assert_contains_run_id(response.messages)
assert_greeting_with_assistant_message_response(response.messages, llm_config=llm_config)
messages_from_db_page = client.agents.messages.list(agent_id=agent_state.id, after=last_message.id if last_message else None)
messages_from_db = messages_from_db_page.items
assert_first_message_is_user_message(messages_from_db)
assert_greeting_with_assistant_message_response(messages_from_db, from_db=True, llm_config=llm_config)
@pytest.mark.parametrize(
"llm_config",
TESTED_LLM_CONFIGS,
ids=[c.model for c in TESTED_LLM_CONFIGS],
)
def test_input_parameter_streaming(
disable_e2b_api_key: Any,
client: Letta,
agent_state: AgentState,
llm_config: LLMConfig,
) -> None:
"""
Tests sending a streaming message using the input parameter.
"""
last_message_page = client.agents.messages.list(agent_id=agent_state.id, limit=1)
last_message = last_message_page.items[0] if last_message_page.items else None
agent_state = client.agents.modify(agent_id=agent_state.id, llm_config=llm_config)
response = client.agents.messages.stream(
agent_id=agent_state.id,
input=f"This is an automated test message. Call the send_message tool with the message '{USER_MESSAGE_RESPONSE}'.",
)
chunks = list(response)
assert_contains_step_id(chunks)
assert_contains_run_id(chunks)
messages = accumulate_chunks(chunks)
assert_greeting_with_assistant_message_response(messages, streaming=True, llm_config=llm_config)
messages_from_db_page = client.agents.messages.list(agent_id=agent_state.id, after=last_message.id if last_message else None)
messages_from_db = messages_from_db_page.items
assert_contains_run_id(messages_from_db)
assert_greeting_with_assistant_message_response(messages_from_db, from_db=True, llm_config=llm_config)
@pytest.mark.parametrize(
"llm_config",
TESTED_LLM_CONFIGS,
ids=[c.model for c in TESTED_LLM_CONFIGS],
)
def test_input_parameter_async(
disable_e2b_api_key: Any,
client: Letta,
agent_state: AgentState,
llm_config: LLMConfig,
) -> None:
"""
Tests sending an async message using the input parameter.
"""
last_message_page = client.agents.messages.list(agent_id=agent_state.id, limit=1)
last_message = last_message_page.items[0] if last_message_page.items else None
client.agents.modify(agent_id=agent_state.id, llm_config=llm_config)
run = client.agents.messages.send_async(
agent_id=agent_state.id,
input=f"This is an automated test message. Call the send_message tool with the message '{USER_MESSAGE_RESPONSE}'.",
)
run = wait_for_run_completion(client, run.id, timeout=60.0)
messages_page = client.runs.messages.list(run_id=run.id)
messages = messages_page.items
assert_greeting_with_assistant_message_response(messages, from_db=True, llm_config=llm_config)
messages_from_db_page = client.agents.messages.list(agent_id=agent_state.id, after=last_message.id if last_message else None)
messages_from_db = messages_from_db_page.items
assert_greeting_with_assistant_message_response(messages_from_db, from_db=True, llm_config=llm_config)
def test_input_and_messages_both_provided_error(
disable_e2b_api_key: Any,
client: Letta,
agent_state: AgentState,
) -> None:
"""
Tests that providing both input and messages raises a validation error.
"""
with pytest.raises(APIError) as exc_info:
client.agents.messages.send(
agent_id=agent_state.id,
input="This is a test message",
messages=USER_MESSAGE_FORCE_REPLY,
)
# Should get a 422 validation error
assert exc_info.value.status_code == 422
def test_input_and_messages_neither_provided_error(
disable_e2b_api_key: Any,
client: Letta,
agent_state: AgentState,
) -> None:
"""
Tests that providing neither input nor messages raises a validation error.
"""
with pytest.raises(APIError) as exc_info:
client.agents.messages.send(
agent_id=agent_state.id,
)
# Should get a 422 validation error
assert exc_info.value.status_code == 422