feat: add invalid_llm_response stop reason [LET-4083] (#4269)

* feat: add invalid_llm_response stop reason

* add sqllite support

* simply skip for sqllite

* fix imports

* fix isort
This commit is contained in:
cthomas
2025-08-27 17:07:47 -07:00
committed by GitHub
parent 3dd5747627
commit 3e6a223d17
4 changed files with 75 additions and 11 deletions

View File

@@ -0,0 +1,39 @@
"""convert_stop_reason_from_enum_to_string
Revision ID: 887a4367b560
Revises: d5103ee17ed5
Create Date: 2025-08-27 16:34:45.605580
"""
from typing import Sequence, Union
from alembic import op
from letta.settings import settings
# revision identifiers, used by Alembic.
revision: str = "887a4367b560"
down_revision: Union[str, None] = "d5103ee17ed5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Skip this migration for SQLite it doesn't enforce column types strictly,
# so the existing enum values will continue to work as strings.
if not settings.letta_pg_uri_no_default:
return
op.execute(
"""
ALTER TABLE steps
ALTER COLUMN stop_reason TYPE VARCHAR
USING stop_reason::VARCHAR
"""
)
def downgrade() -> None:
# This is a one-way migration as we can't easily recreate the enum type
# If needed, you would need to create the enum type and cast back
pass

View File

@@ -285,7 +285,11 @@ class LettaAgent(BaseAgent):
step_progression = StepProgression.RESPONSE_RECEIVED
log_event("agent.stream_no_tokens.llm_response.received") # [3^]
response = llm_client.convert_response_to_chat_completion(response_data, in_context_messages, agent_state.llm_config)
try:
response = llm_client.convert_response_to_chat_completion(response_data, in_context_messages, agent_state.llm_config)
except ValueError as e:
stop_reason = LettaStopReason(stop_reason=StopReasonType.invalid_llm_response.value)
raise e
# update usage
usage.step_count += 1
@@ -395,8 +399,12 @@ class LettaAgent(BaseAgent):
stop_reason = LettaStopReason(stop_reason=StopReasonType.error.value)
elif stop_reason.stop_reason in (StopReasonType.end_turn, StopReasonType.max_steps, StopReasonType.tool_rule):
self.logger.error("Error occurred during step processing, with valid stop reason: %s", stop_reason.stop_reason)
elif stop_reason.stop_reason not in (StopReasonType.no_tool_call, StopReasonType.invalid_tool_call):
raise ValueError(f"Invalid Stop Reason: {stop_reason}")
elif stop_reason.stop_reason not in (
StopReasonType.no_tool_call,
StopReasonType.invalid_tool_call,
StopReasonType.invalid_llm_response,
):
self.logger.error("Error occurred during step processing, with unexpected stop reason: %s", stop_reason.stop_reason)
# Send error stop reason to client and re-raise
yield f"data: {stop_reason.model_dump_json()}\n\n", 500
@@ -582,7 +590,11 @@ class LettaAgent(BaseAgent):
step_progression = StepProgression.RESPONSE_RECEIVED
log_event("agent.step.llm_response.received") # [3^]
response = llm_client.convert_response_to_chat_completion(response_data, in_context_messages, agent_state.llm_config)
try:
response = llm_client.convert_response_to_chat_completion(response_data, in_context_messages, agent_state.llm_config)
except ValueError as e:
stop_reason = LettaStopReason(stop_reason=StopReasonType.invalid_llm_response.value)
raise e
usage.step_count += 1
usage.completion_tokens += response.usage.completion_tokens
@@ -683,8 +695,12 @@ class LettaAgent(BaseAgent):
stop_reason = LettaStopReason(stop_reason=StopReasonType.error.value)
elif stop_reason.stop_reason in (StopReasonType.end_turn, StopReasonType.max_steps, StopReasonType.tool_rule):
self.logger.error("Error occurred during step processing, with valid stop reason: %s", stop_reason.stop_reason)
elif stop_reason.stop_reason not in (StopReasonType.no_tool_call, StopReasonType.invalid_tool_call):
raise ValueError(f"Invalid Stop Reason: {stop_reason}")
elif stop_reason.stop_reason not in (
StopReasonType.no_tool_call,
StopReasonType.invalid_tool_call,
StopReasonType.invalid_llm_response,
):
self.logger.error("Error occurred during step processing, with unexpected stop reason: %s", stop_reason.stop_reason)
raise
# Update step if it needs to be updated
@@ -1076,8 +1092,12 @@ class LettaAgent(BaseAgent):
stop_reason = LettaStopReason(stop_reason=StopReasonType.error.value)
elif stop_reason.stop_reason in (StopReasonType.end_turn, StopReasonType.max_steps, StopReasonType.tool_rule):
self.logger.error("Error occurred during step processing, with valid stop reason: %s", stop_reason.stop_reason)
elif stop_reason.stop_reason not in (StopReasonType.no_tool_call, StopReasonType.invalid_tool_call):
raise ValueError(f"Invalid Stop Reason: {stop_reason}")
elif stop_reason.stop_reason not in (
StopReasonType.no_tool_call,
StopReasonType.invalid_tool_call,
StopReasonType.invalid_llm_response,
):
self.logger.error("Error occurred during step processing, with unexpected stop reason: %s", stop_reason.stop_reason)
# Send error stop reason to client and re-raise with expected response code
yield f"data: {stop_reason.model_dump_json()}\n\n", 500

View File

@@ -7,7 +7,6 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from letta.orm.mixins import ProjectMixin
from letta.orm.sqlalchemy_base import SqlalchemyBase
from letta.schemas.enums import StepStatus
from letta.schemas.letta_stop_reason import StopReasonType
from letta.schemas.step import Step as PydanticStep
if TYPE_CHECKING:
@@ -51,7 +50,7 @@ class Step(SqlalchemyBase, ProjectMixin):
prompt_tokens: Mapped[int] = mapped_column(default=0, doc="Number of tokens in the prompt")
total_tokens: Mapped[int] = mapped_column(default=0, doc="Total number of tokens processed by the agent")
completion_tokens_details: Mapped[Optional[Dict]] = mapped_column(JSON, nullable=True, doc="metadata for the agent.")
stop_reason: Mapped[Optional[StopReasonType]] = mapped_column(None, nullable=True, doc="The stop reason associated with this step.")
stop_reason: Mapped[Optional[str]] = mapped_column(None, nullable=True, doc="The stop reason associated with this step.")
tags: Mapped[Optional[List]] = mapped_column(JSON, doc="Metadata tags.")
tid: Mapped[Optional[str]] = mapped_column(None, nullable=True, doc="Transaction ID that processed the step.")
trace_id: Mapped[Optional[str]] = mapped_column(None, nullable=True, doc="The trace id of the agent step.")

View File

@@ -9,6 +9,7 @@ from letta.schemas.enums import JobStatus
class StopReasonType(str, Enum):
end_turn = "end_turn"
error = "error"
invalid_llm_response = "invalid_llm_response"
invalid_tool_call = "invalid_tool_call"
max_steps = "max_steps"
no_tool_call = "no_tool_call"
@@ -23,7 +24,12 @@ class StopReasonType(str, Enum):
StopReasonType.tool_rule,
):
return JobStatus.completed
elif self in (StopReasonType.error, StopReasonType.invalid_tool_call, StopReasonType.no_tool_call):
elif self in (
StopReasonType.error,
StopReasonType.invalid_tool_call,
StopReasonType.no_tool_call,
StopReasonType.invalid_llm_response,
):
return JobStatus.failed
elif self == StopReasonType.cancelled:
return JobStatus.cancelled