feat: add input option to send message route [LET-4540] (#5938)
--------- Co-authored-by: Ari Webb <ari@letta.com>
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user