feat: add status to runs api [PRO-1529] (#5214)

This commit is contained in:
Shelley Pham
2025-10-07 13:30:13 -07:00
committed by Caren Thomas
parent 5da764a65c
commit 346db5ce60
4 changed files with 257 additions and 3 deletions

View File

@@ -10185,6 +10185,27 @@
"description": "The unique identifiers of the agents associated with the run. Deprecated in favor of agent_id field.",
"deprecated": true
},
{
"name": "statuses",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "array",
"items": {
"type": "string"
}
},
{
"type": "null"
}
],
"description": "Filter runs by status. Can specify multiple statuses.",
"title": "Statuses"
},
"description": "Filter runs by status. Can specify multiple statuses."
},
{
"name": "background",
"in": "query",

View File

@@ -29,6 +29,20 @@ from letta.settings import settings
router = APIRouter(prefix="/runs", tags=["runs"])
def convert_statuses_to_enum(statuses: Optional[List[str]]) -> Optional[List[RunStatus]]:
"""Convert a list of status strings to RunStatus enum values.
Args:
statuses: List of status strings or None
Returns:
List of RunStatus enum values or None if input is None
"""
if statuses is None:
return None
return [RunStatus(status) for status in statuses]
@router.get("/", response_model=List[Run], operation_id="list_runs")
async def list_runs(
server: "SyncServer" = Depends(get_letta_server),
@@ -38,6 +52,7 @@ async def list_runs(
description="The unique identifiers of the agents associated with the run. Deprecated in favor of agent_id field.",
deprecated=True,
),
statuses: Optional[List[str]] = Query(None, description="Filter runs by status. Can specify multiple statuses."),
background: Optional[bool] = Query(None, description="If True, filters for runs that were created in background mode."),
stop_reason: Optional[StopReasonType] = Query(None, description="Filter runs by stop reason."),
before: Optional[str] = Query(
@@ -65,9 +80,10 @@ async def list_runs(
actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id)
runs_manager = RunManager()
statuses = None
if active:
# Handle backwards compatibility: if statuses not provided but active=True, filter by active statuses
if statuses is None and active:
statuses = [RunStatus.created, RunStatus.running]
if agent_id:
# NOTE: we are deprecating agent_ids so this will the primary path soon
agent_ids = [agent_id]
@@ -80,10 +96,13 @@ async def list_runs(
# Use the new order parameter
sort_ascending = order == "asc"
# Convert string statuses to RunStatus enum
parsed_statuses = convert_statuses_to_enum(statuses)
runs = await runs_manager.list_runs(
actor=actor,
agent_ids=agent_ids,
statuses=statuses,
statuses=parsed_statuses,
limit=limit,
before=before,
after=after,

View File

@@ -1146,3 +1146,137 @@ async def test_get_run_request_config_nonexistent_run(server: SyncServer, defaul
# # Try to record response duration for non-existent job - should not raise exception but log warning
# await server.job_manager.record_response_duration("nonexistent_job_id", 2_000_000_000, default_user)
#
# ======================================================================================================================
# convert_statuses_to_enum Tests
# ======================================================================================================================
def test_convert_statuses_to_enum_with_none():
"""Test that convert_statuses_to_enum returns None when input is None."""
from letta.server.rest_api.routers.v1.runs import convert_statuses_to_enum
result = convert_statuses_to_enum(None)
assert result is None
def test_convert_statuses_to_enum_with_single_status():
"""Test converting a single status string to RunStatus enum."""
from letta.server.rest_api.routers.v1.runs import convert_statuses_to_enum
result = convert_statuses_to_enum(["completed"])
assert result == [RunStatus.completed]
assert len(result) == 1
def test_convert_statuses_to_enum_with_multiple_statuses():
"""Test converting multiple status strings to RunStatus enums."""
from letta.server.rest_api.routers.v1.runs import convert_statuses_to_enum
result = convert_statuses_to_enum(["created", "running", "completed"])
assert result == [RunStatus.created, RunStatus.running, RunStatus.completed]
assert len(result) == 3
def test_convert_statuses_to_enum_with_all_statuses():
"""Test converting all possible status strings."""
from letta.server.rest_api.routers.v1.runs import convert_statuses_to_enum
all_statuses = ["created", "running", "completed", "failed", "cancelled"]
result = convert_statuses_to_enum(all_statuses)
assert result == [RunStatus.created, RunStatus.running, RunStatus.completed, RunStatus.failed, RunStatus.cancelled]
assert len(result) == 5
def test_convert_statuses_to_enum_with_empty_list():
"""Test converting an empty list."""
from letta.server.rest_api.routers.v1.runs import convert_statuses_to_enum
result = convert_statuses_to_enum([])
assert result == []
def test_convert_statuses_to_enum_with_invalid_status():
"""Test that invalid status strings raise ValueError."""
from letta.server.rest_api.routers.v1.runs import convert_statuses_to_enum
with pytest.raises(ValueError):
convert_statuses_to_enum(["invalid_status"])
@pytest.mark.asyncio
async def test_list_runs_with_multiple_statuses(server: SyncServer, sarah_agent, default_user):
"""Test listing runs with multiple status filters."""
# Create runs with different statuses
run_created = await server.run_manager.create_run(
pydantic_run=PydanticRun(
status=RunStatus.created,
agent_id=sarah_agent.id,
metadata={"type": "created"},
),
actor=default_user,
)
run_running = await server.run_manager.create_run(
pydantic_run=PydanticRun(
status=RunStatus.running,
agent_id=sarah_agent.id,
metadata={"type": "running"},
),
actor=default_user,
)
run_completed = await server.run_manager.create_run(
pydantic_run=PydanticRun(
status=RunStatus.completed,
agent_id=sarah_agent.id,
metadata={"type": "completed"},
),
actor=default_user,
)
run_failed = await server.run_manager.create_run(
pydantic_run=PydanticRun(
status=RunStatus.failed,
agent_id=sarah_agent.id,
metadata={"type": "failed"},
),
actor=default_user,
)
# Test filtering by multiple statuses
active_runs = await server.run_manager.list_runs(
actor=default_user, statuses=[RunStatus.created, RunStatus.running], agent_id=sarah_agent.id
)
assert len(active_runs) == 2
assert all(run.status in [RunStatus.created, RunStatus.running] for run in active_runs)
# Test filtering by terminal statuses
terminal_runs = await server.run_manager.list_runs(
actor=default_user, statuses=[RunStatus.completed, RunStatus.failed], agent_id=sarah_agent.id
)
assert len(terminal_runs) == 2
assert all(run.status in [RunStatus.completed, RunStatus.failed] for run in terminal_runs)
@pytest.mark.asyncio
async def test_list_runs_with_no_status_filter_returns_all(server: SyncServer, sarah_agent, default_user):
"""Test that not providing statuses parameter returns all runs."""
# Create runs with different statuses
await server.run_manager.create_run(pydantic_run=PydanticRun(status=RunStatus.created, agent_id=sarah_agent.id), actor=default_user)
await server.run_manager.create_run(pydantic_run=PydanticRun(status=RunStatus.running, agent_id=sarah_agent.id), actor=default_user)
await server.run_manager.create_run(pydantic_run=PydanticRun(status=RunStatus.completed, agent_id=sarah_agent.id), actor=default_user)
await server.run_manager.create_run(pydantic_run=PydanticRun(status=RunStatus.failed, agent_id=sarah_agent.id), actor=default_user)
await server.run_manager.create_run(pydantic_run=PydanticRun(status=RunStatus.cancelled, agent_id=sarah_agent.id), actor=default_user)
# List all runs without status filter
all_runs = await server.run_manager.list_runs(actor=default_user, agent_id=sarah_agent.id)
# Should return all 5 runs
assert len(all_runs) >= 5
# Verify we have all statuses represented
statuses_found = {run.status for run in all_runs}
assert RunStatus.created in statuses_found
assert RunStatus.running in statuses_found
assert RunStatus.completed in statuses_found
assert RunStatus.failed in statuses_found
assert RunStatus.cancelled in statuses_found

View File

@@ -0,0 +1,80 @@
"""
Unit tests for the convert_statuses_to_enum function in the runs API router.
These tests verify that status string conversion to RunStatus enums works correctly.
"""
import pytest
from letta.schemas.enums import RunStatus
from letta.server.rest_api.routers.v1.runs import convert_statuses_to_enum
def test_convert_statuses_to_enum_with_none():
"""Test that convert_statuses_to_enum returns None when input is None."""
result = convert_statuses_to_enum(None)
assert result is None
def test_convert_statuses_to_enum_with_single_status():
"""Test converting a single status string to RunStatus enum."""
result = convert_statuses_to_enum(["completed"])
assert result == [RunStatus.completed]
assert len(result) == 1
def test_convert_statuses_to_enum_with_multiple_statuses():
"""Test converting multiple status strings to RunStatus enums."""
result = convert_statuses_to_enum(["created", "running", "completed"])
assert result == [RunStatus.created, RunStatus.running, RunStatus.completed]
assert len(result) == 3
def test_convert_statuses_to_enum_with_all_statuses():
"""Test converting all possible status strings."""
all_statuses = ["created", "running", "completed", "failed", "cancelled"]
result = convert_statuses_to_enum(all_statuses)
assert result == [RunStatus.created, RunStatus.running, RunStatus.completed, RunStatus.failed, RunStatus.cancelled]
assert len(result) == 5
def test_convert_statuses_to_enum_with_empty_list():
"""Test converting an empty list."""
result = convert_statuses_to_enum([])
assert result == []
def test_convert_statuses_to_enum_with_invalid_status():
"""Test that invalid status strings raise ValueError."""
with pytest.raises(ValueError):
convert_statuses_to_enum(["invalid_status"])
def test_convert_statuses_to_enum_preserves_order():
"""Test that the order of statuses is preserved."""
input_statuses = ["failed", "created", "completed", "running"]
result = convert_statuses_to_enum(input_statuses)
assert result == [RunStatus.failed, RunStatus.created, RunStatus.completed, RunStatus.running]
def test_convert_statuses_to_enum_with_duplicate_statuses():
"""Test that duplicate statuses are preserved."""
input_statuses = ["completed", "completed", "running"]
result = convert_statuses_to_enum(input_statuses)
assert result == [RunStatus.completed, RunStatus.completed, RunStatus.running]
assert len(result) == 3
def test_convert_statuses_to_enum_case_sensitivity():
"""Test that the function is case-sensitive and requires exact matches."""
with pytest.raises(ValueError):
convert_statuses_to_enum(["COMPLETED"])
with pytest.raises(ValueError):
convert_statuses_to_enum(["Completed"])
def test_convert_statuses_to_enum_with_mixed_valid_invalid():
"""Test that if any status is invalid, the entire conversion fails."""
with pytest.raises(ValueError):
convert_statuses_to_enum(["completed", "invalid", "running"])