From ed99d7eb2b676727a133311a437f993c522d809f Mon Sep 17 00:00:00 2001 From: Ari Webb Date: Tue, 4 Nov 2025 12:27:01 -0800 Subject: [PATCH] feat: add input option to send message route [LET-4540] (#5938) --------- Co-authored-by: Ari Webb --- fern/openapi.json | 337 +++++++++++++++--- letta/schemas/letta_request.py | 28 +- letta/server/rest_api/routers/v1/agents.py | 3 +- .../integration_test_send_message.py | 138 +++++++ 4 files changed, 457 insertions(+), 49 deletions(-) diff --git a/fern/openapi.json b/fern/openapi.json index 58d5fcea..0f6ea0a1 100644 --- a/fern/openapi.json +++ b/fern/openapi.json @@ -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": { diff --git a/letta/schemas/letta_request.py b/letta/schemas/letta_request.py index a9a83bb4..8d37a5da 100644 --- a/letta/schemas/letta_request.py +++ b/letta/schemas/letta_request.py @@ -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( diff --git a/letta/server/rest_api/routers/v1/agents.py b/letta/server/rest_api/routers/v1/agents.py index 60f86dd2..558c9422 100644 --- a/letta/server/rest_api/routers/v1/agents.py +++ b/letta/server/rest_api/routers/v1/agents.py @@ -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()) diff --git a/tests/sdk_v1/integration/integration_test_send_message.py b/tests/sdk_v1/integration/integration_test_send_message.py index 97db12a1..b9a35d6f 100644 --- a/tests/sdk_v1/integration/integration_test_send_message.py +++ b/tests/sdk_v1/integration/integration_test_send_message.py @@ -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