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
This commit is contained in:
Matthew Zhou
2025-10-07 12:00:45 -07:00
committed by Caren Thomas
parent c31521c7ad
commit 0f27bf5bdd
4 changed files with 33 additions and 5 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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):

View File

@@ -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,