From 0f27bf5bdd8e092fa8e6f403a64e6d046ab36f15 Mon Sep 17 00:00:00 2001 From: Matthew Zhou Date: Tue, 7 Oct 2025 12:00:45 -0700 Subject: [PATCH] feat: Support writing packed func return to `ToolReturn` object (#5209) * wip * Add func_response to ToolReturn object * Fix response packaging * Backpopulate in to_pydantic * Fix ordering of packaging * Add more checks --- fern/openapi.json | 12 ++++++++++++ letta/orm/message.py | 15 ++++++++++++++- letta/schemas/message.py | 6 ++++-- letta/server/rest_api/utils.py | 5 +++-- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/fern/openapi.json b/fern/openapi.json index 96d2d58d..5a86162e 100644 --- a/fern/openapi.json +++ b/fern/openapi.json @@ -33961,6 +33961,18 @@ ], "title": "Stderr", "description": "Captured stderr from the tool invocation" + }, + "func_response": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Func Response", + "description": "The function response string" } }, "type": "object", diff --git a/letta/orm/message.py b/letta/orm/message.py index 86904e67..e7ddda0e 100644 --- a/letta/orm/message.py +++ b/letta/orm/message.py @@ -7,7 +7,8 @@ from sqlalchemy.orm import Mapped, Session, mapped_column, relationship from letta.orm.custom_columns import MessageContentColumn, ToolCallColumn, ToolReturnColumn from letta.orm.mixins import AgentMixin, OrganizationMixin from letta.orm.sqlalchemy_base import SqlalchemyBase -from letta.schemas.letta_message_content import MessageContent, TextContent as PydanticTextContent +from letta.schemas.enums import MessageRole +from letta.schemas.letta_message_content import MessageContent, TextContent, TextContent as PydanticTextContent from letta.schemas.message import Message as PydanticMessage, ToolReturn from letta.settings import DatabaseChoice, settings @@ -89,6 +90,18 @@ class Message(SqlalchemyBase, OrganizationMixin, AgentMixin): # If there are no tool calls, set tool_calls to None if self.tool_calls is None or len(self.tool_calls) == 0: model.tool_calls = None + + # Handle legacy case of tool message with single tool return + single text content + if ( + self.role == MessageRole.tool + and self.tool_returns + and len(self.tool_returns) == 1 + and self.content + and len(self.content) == 1 + and isinstance(self.content[0], TextContent) + ): + self.tool_returns[0].func_response = self.content[0].text + return model diff --git a/letta/schemas/message.py b/letta/schemas/message.py index f4f10829..d6c2b02b 100644 --- a/letta/schemas/message.py +++ b/letta/schemas/message.py @@ -547,7 +547,9 @@ class Message(BaseMessage): "time": formatted_time, } """ - if self.content and len(self.content) == 1 and isinstance(self.content[0], TextContent): + if self.tool_returns and len(self.tool_returns) == 1: + text_content = self.tool_returns[0].func_response + elif self.content and len(self.content) == 1 and isinstance(self.content[0], TextContent): text_content = self.content[0].text else: raise ValueError(f"Invalid tool return (no text object on message): {self.content}") @@ -1506,7 +1508,7 @@ class ToolReturn(BaseModel): status: Literal["success", "error"] = Field(..., description="The status of the tool call") stdout: Optional[List[str]] = Field(default=None, description="Captured stdout (e.g. prints, logs) from the tool invocation") stderr: Optional[List[str]] = Field(default=None, description="Captured stderr from the tool invocation") - # func_return: Optional[Any] = Field(None, description="The function return object") + func_response: Optional[str] = Field(None, description="The function response string") class MessageSearchRequest(BaseModel): diff --git a/letta/server/rest_api/utils.py b/letta/server/rest_api/utils.py index 503c486e..bb3b9028 100644 --- a/letta/server/rest_api/utils.py +++ b/letta/server/rest_api/utils.py @@ -319,9 +319,10 @@ def create_letta_messages_from_llm_response( # TODO: Use ToolReturnContent instead of TextContent # TODO: This helps preserve ordering if tool_execution_result is not None: + packaged_function_response = package_function_response(tool_execution_result.success_flag, function_response, timezone) tool_message = Message( role=MessageRole.tool, - content=[TextContent(text=package_function_response(tool_execution_result.success_flag, function_response, timezone))], + content=[TextContent(text=packaged_function_response)], agent_id=agent_id, model=model, tool_calls=[], @@ -335,7 +336,7 @@ def create_letta_messages_from_llm_response( status=tool_execution_result.status, stderr=tool_execution_result.stderr, stdout=tool_execution_result.stdout, - # func_return=tool_execution_result.func_return, + func_response=packaged_function_response, ) ], run_id=run_id,