feat: add status to runs api [PRO-1529] (#5214)
This commit is contained in:
committed by
Caren Thomas
parent
5da764a65c
commit
346db5ce60
@@ -10185,6 +10185,27 @@
|
|||||||
"description": "The unique identifiers of the agents associated with the run. Deprecated in favor of agent_id field.",
|
"description": "The unique identifiers of the agents associated with the run. Deprecated in favor of agent_id field.",
|
||||||
"deprecated": true
|
"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",
|
"name": "background",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ from letta.settings import settings
|
|||||||
router = APIRouter(prefix="/runs", tags=["runs"])
|
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")
|
@router.get("/", response_model=List[Run], operation_id="list_runs")
|
||||||
async def list_runs(
|
async def list_runs(
|
||||||
server: "SyncServer" = Depends(get_letta_server),
|
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.",
|
description="The unique identifiers of the agents associated with the run. Deprecated in favor of agent_id field.",
|
||||||
deprecated=True,
|
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."),
|
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."),
|
stop_reason: Optional[StopReasonType] = Query(None, description="Filter runs by stop reason."),
|
||||||
before: Optional[str] = Query(
|
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)
|
actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id)
|
||||||
runs_manager = RunManager()
|
runs_manager = RunManager()
|
||||||
|
|
||||||
statuses = None
|
# Handle backwards compatibility: if statuses not provided but active=True, filter by active statuses
|
||||||
if active:
|
if statuses is None and active:
|
||||||
statuses = [RunStatus.created, RunStatus.running]
|
statuses = [RunStatus.created, RunStatus.running]
|
||||||
|
|
||||||
if agent_id:
|
if agent_id:
|
||||||
# NOTE: we are deprecating agent_ids so this will the primary path soon
|
# NOTE: we are deprecating agent_ids so this will the primary path soon
|
||||||
agent_ids = [agent_id]
|
agent_ids = [agent_id]
|
||||||
@@ -80,10 +96,13 @@ async def list_runs(
|
|||||||
# Use the new order parameter
|
# Use the new order parameter
|
||||||
sort_ascending = order == "asc"
|
sort_ascending = order == "asc"
|
||||||
|
|
||||||
|
# Convert string statuses to RunStatus enum
|
||||||
|
parsed_statuses = convert_statuses_to_enum(statuses)
|
||||||
|
|
||||||
runs = await runs_manager.list_runs(
|
runs = await runs_manager.list_runs(
|
||||||
actor=actor,
|
actor=actor,
|
||||||
agent_ids=agent_ids,
|
agent_ids=agent_ids,
|
||||||
statuses=statuses,
|
statuses=parsed_statuses,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
before=before,
|
before=before,
|
||||||
after=after,
|
after=after,
|
||||||
|
|||||||
@@ -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
|
# # 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)
|
# 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
|
||||||
|
|||||||
80
tests/test_run_status_conversion.py
Normal file
80
tests/test_run_status_conversion.py
Normal 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"])
|
||||||
Reference in New Issue
Block a user