feat: support default convo in list and cancel endpoints (#9707)
* feat: support default convo in list and cancel endpoints * also support in compact endpoint * api sync
This commit is contained in:
@@ -9003,7 +9003,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"tags": ["conversations"],
|
"tags": ["conversations"],
|
||||||
"summary": "List Conversation Messages",
|
"summary": "List Conversation Messages",
|
||||||
"description": "List all messages in a conversation.\n\nReturns LettaMessage objects (UserMessage, AssistantMessage, etc.) for all\nmessages in the conversation, with support for cursor-based pagination.",
|
"description": "List all messages in a conversation.\n\nReturns LettaMessage objects (UserMessage, AssistantMessage, etc.) for all\nmessages in the conversation, with support for cursor-based pagination.\n\nIf conversation_id is an agent ID (starts with \"agent-\"), returns messages\nfrom the agent's default conversation (no conversation isolation).",
|
||||||
"operationId": "list_conversation_messages",
|
"operationId": "list_conversation_messages",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -9238,7 +9238,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"tags": ["conversations"],
|
"tags": ["conversations"],
|
||||||
"summary": "Retrieve Conversation Stream",
|
"summary": "Retrieve Conversation Stream",
|
||||||
"description": "Resume the stream for the most recent active run in a conversation.\n\nThis endpoint allows you to reconnect to an active background stream\nfor a conversation, enabling recovery from network interruptions.",
|
"description": "Resume the stream for the most recent active run in a conversation.\n\nThis endpoint allows you to reconnect to an active background stream\nfor a conversation, enabling recovery from network interruptions.\n\nIf conversation_id is an agent ID (starts with \"agent-\"), retrieves the\nstream for the agent's most recent active run.",
|
||||||
"operationId": "retrieve_conversation_stream",
|
"operationId": "retrieve_conversation_stream",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -9342,7 +9342,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"tags": ["conversations"],
|
"tags": ["conversations"],
|
||||||
"summary": "Cancel Conversation",
|
"summary": "Cancel Conversation",
|
||||||
"description": "Cancel runs associated with a conversation.\n\nNote: To cancel active runs, Redis is required.",
|
"description": "Cancel runs associated with a conversation.\n\nNote: To cancel active runs, Redis is required.\n\nIf conversation_id is an agent ID (starts with \"agent-\"), cancels runs\nfor the agent's default conversation.",
|
||||||
"operationId": "cancel_conversation",
|
"operationId": "cancel_conversation",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -9395,7 +9395,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"tags": ["conversations"],
|
"tags": ["conversations"],
|
||||||
"summary": "Compact Conversation",
|
"summary": "Compact Conversation",
|
||||||
"description": "Compact (summarize) a conversation's message history.\n\nThis endpoint summarizes the in-context messages for a specific conversation,\nreducing the message count while preserving important context.",
|
"description": "Compact (summarize) a conversation's message history.\n\nThis endpoint summarizes the in-context messages for a specific conversation,\nreducing the message count while preserving important context.\n\nIf conversation_id is an agent ID (starts with \"agent-\"), compacts the\nagent's default conversation messages.",
|
||||||
"operationId": "compact_conversation",
|
"operationId": "compact_conversation",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -173,8 +173,27 @@ async def list_conversation_messages(
|
|||||||
|
|
||||||
Returns LettaMessage objects (UserMessage, AssistantMessage, etc.) for all
|
Returns LettaMessage objects (UserMessage, AssistantMessage, etc.) for all
|
||||||
messages in the conversation, with support for cursor-based pagination.
|
messages in the conversation, with support for cursor-based pagination.
|
||||||
|
|
||||||
|
If conversation_id is an agent ID (starts with "agent-"), returns messages
|
||||||
|
from the agent's default conversation (no conversation isolation).
|
||||||
"""
|
"""
|
||||||
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)
|
||||||
|
|
||||||
|
# Agent-direct mode: list agent's default conversation messages
|
||||||
|
if conversation_id.startswith("agent-"):
|
||||||
|
return await server.get_agent_recall_async(
|
||||||
|
agent_id=conversation_id,
|
||||||
|
after=after,
|
||||||
|
before=before,
|
||||||
|
limit=limit,
|
||||||
|
group_id=group_id,
|
||||||
|
conversation_id=None, # Default conversation (no isolation)
|
||||||
|
reverse=(order == "desc"),
|
||||||
|
return_message_object=False,
|
||||||
|
include_err=include_err,
|
||||||
|
actor=actor,
|
||||||
|
)
|
||||||
|
|
||||||
return await conversation_manager.list_conversation_messages(
|
return await conversation_manager.list_conversation_messages(
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
actor=actor,
|
actor=actor,
|
||||||
@@ -468,18 +487,32 @@ async def retrieve_conversation_stream(
|
|||||||
|
|
||||||
This endpoint allows you to reconnect to an active background stream
|
This endpoint allows you to reconnect to an active background stream
|
||||||
for a conversation, enabling recovery from network interruptions.
|
for a conversation, enabling recovery from network interruptions.
|
||||||
|
|
||||||
|
If conversation_id is an agent ID (starts with "agent-"), retrieves the
|
||||||
|
stream for the agent's most recent active run.
|
||||||
"""
|
"""
|
||||||
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()
|
||||||
|
|
||||||
# Find the most recent active run for this conversation
|
# Find the most recent active run
|
||||||
active_runs = await runs_manager.list_runs(
|
if conversation_id.startswith("agent-"):
|
||||||
actor=actor,
|
# Agent-direct mode: find runs by agent_id
|
||||||
conversation_id=conversation_id,
|
active_runs = await runs_manager.list_runs(
|
||||||
statuses=[RunStatus.created, RunStatus.running],
|
actor=actor,
|
||||||
limit=1,
|
agent_id=conversation_id,
|
||||||
ascending=False,
|
statuses=[RunStatus.created, RunStatus.running],
|
||||||
)
|
limit=1,
|
||||||
|
ascending=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Normal mode: find runs by conversation_id
|
||||||
|
active_runs = await runs_manager.list_runs(
|
||||||
|
actor=actor,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
statuses=[RunStatus.created, RunStatus.running],
|
||||||
|
limit=1,
|
||||||
|
ascending=False,
|
||||||
|
)
|
||||||
|
|
||||||
if not active_runs:
|
if not active_runs:
|
||||||
raise LettaInvalidArgumentError("No active runs found for this conversation.")
|
raise LettaInvalidArgumentError("No active runs found for this conversation.")
|
||||||
@@ -542,26 +575,43 @@ async def cancel_conversation(
|
|||||||
Cancel runs associated with a conversation.
|
Cancel runs associated with a conversation.
|
||||||
|
|
||||||
Note: To cancel active runs, Redis is required.
|
Note: To cancel active runs, Redis is required.
|
||||||
|
|
||||||
|
If conversation_id is an agent ID (starts with "agent-"), cancels runs
|
||||||
|
for the agent's default conversation.
|
||||||
"""
|
"""
|
||||||
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)
|
||||||
|
|
||||||
if not settings.track_agent_run:
|
if not settings.track_agent_run:
|
||||||
raise HTTPException(status_code=400, detail="Agent run tracking is disabled")
|
raise HTTPException(status_code=400, detail="Agent run tracking is disabled")
|
||||||
|
|
||||||
# Verify conversation exists and get agent_id
|
# Agent-direct mode: use agent_id directly, skip conversation lookup
|
||||||
conversation = await conversation_manager.get_conversation_by_id(
|
if conversation_id.startswith("agent-"):
|
||||||
conversation_id=conversation_id,
|
agent_id = conversation_id
|
||||||
actor=actor,
|
# Find active runs for this agent (default conversation has conversation_id=None)
|
||||||
)
|
runs = await server.run_manager.list_runs(
|
||||||
|
actor=actor,
|
||||||
|
agent_id=agent_id,
|
||||||
|
statuses=[RunStatus.created, RunStatus.running],
|
||||||
|
ascending=False,
|
||||||
|
limit=100,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Verify conversation exists and get agent_id
|
||||||
|
conversation = await conversation_manager.get_conversation_by_id(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
actor=actor,
|
||||||
|
)
|
||||||
|
agent_id = conversation.agent_id
|
||||||
|
|
||||||
|
# Find active runs for this conversation
|
||||||
|
runs = await server.run_manager.list_runs(
|
||||||
|
actor=actor,
|
||||||
|
statuses=[RunStatus.created, RunStatus.running],
|
||||||
|
ascending=False,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
limit=100,
|
||||||
|
)
|
||||||
|
|
||||||
# Find active runs for this conversation
|
|
||||||
runs = await server.run_manager.list_runs(
|
|
||||||
actor=actor,
|
|
||||||
statuses=[RunStatus.created, RunStatus.running],
|
|
||||||
ascending=False,
|
|
||||||
conversation_id=conversation_id,
|
|
||||||
limit=100,
|
|
||||||
)
|
|
||||||
run_ids = [run.id for run in runs]
|
run_ids = [run.id for run in runs]
|
||||||
|
|
||||||
if not run_ids:
|
if not run_ids:
|
||||||
@@ -578,7 +628,7 @@ async def cancel_conversation(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to cancel Lettuce run {run_id}: {e}")
|
logger.error(f"Failed to cancel Lettuce run {run_id}: {e}")
|
||||||
|
|
||||||
await server.run_manager.cancel_run(actor=actor, agent_id=conversation.agent_id, run_id=run_id)
|
await server.run_manager.cancel_run(actor=actor, agent_id=agent_id, run_id=run_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results[run_id] = "failed"
|
results[run_id] = "failed"
|
||||||
logger.error(f"Failed to cancel run {run_id}: {str(e)}")
|
logger.error(f"Failed to cancel run {run_id}: {str(e)}")
|
||||||
@@ -614,23 +664,36 @@ async def compact_conversation(
|
|||||||
|
|
||||||
This endpoint summarizes the in-context messages for a specific conversation,
|
This endpoint summarizes the in-context messages for a specific conversation,
|
||||||
reducing the message count while preserving important context.
|
reducing the message count while preserving important context.
|
||||||
|
|
||||||
|
If conversation_id is an agent ID (starts with "agent-"), compacts the
|
||||||
|
agent's default conversation messages.
|
||||||
"""
|
"""
|
||||||
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)
|
||||||
|
|
||||||
# Get the conversation to find the agent_id
|
# Agent-direct mode: compact agent's default conversation
|
||||||
conversation = await conversation_manager.get_conversation_by_id(
|
if conversation_id.startswith("agent-"):
|
||||||
conversation_id=conversation_id,
|
agent_id = conversation_id
|
||||||
actor=actor,
|
agent = await server.agent_manager.get_agent_by_id_async(agent_id, actor, include_relationships=["multi_agent_group"])
|
||||||
)
|
in_context_messages = await server.message_manager.get_messages_by_ids_async(message_ids=agent.message_ids, actor=actor)
|
||||||
|
agent_loop = LettaAgentV3(agent_state=agent, actor=actor)
|
||||||
|
else:
|
||||||
|
# Get the conversation to find the agent_id
|
||||||
|
conversation = await conversation_manager.get_conversation_by_id(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
actor=actor,
|
||||||
|
)
|
||||||
|
|
||||||
# Get the agent state
|
# Get the agent state
|
||||||
agent = await server.agent_manager.get_agent_by_id_async(conversation.agent_id, actor, include_relationships=["multi_agent_group"])
|
agent = await server.agent_manager.get_agent_by_id_async(conversation.agent_id, actor, include_relationships=["multi_agent_group"])
|
||||||
|
|
||||||
# Get in-context messages for this conversation
|
# Get in-context messages for this conversation
|
||||||
in_context_messages = await conversation_manager.get_messages_for_conversation(
|
in_context_messages = await conversation_manager.get_messages_for_conversation(
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
actor=actor,
|
actor=actor,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create agent loop with conversation context
|
||||||
|
agent_loop = LettaAgentV3(agent_state=agent, actor=actor, conversation_id=conversation_id)
|
||||||
|
|
||||||
if not in_context_messages:
|
if not in_context_messages:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -638,9 +701,6 @@ async def compact_conversation(
|
|||||||
detail="No in-context messages found for this conversation.",
|
detail="No in-context messages found for this conversation.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create agent loop with conversation context
|
|
||||||
agent_loop = LettaAgentV3(agent_state=agent, actor=actor, conversation_id=conversation_id)
|
|
||||||
|
|
||||||
compaction_settings = request.compaction_settings if request else None
|
compaction_settings = request.compaction_settings if request else None
|
||||||
num_messages_before = len(in_context_messages)
|
num_messages_before = len(in_context_messages)
|
||||||
|
|
||||||
|
|||||||
@@ -675,6 +675,56 @@ class TestConversationsSDK:
|
|||||||
)
|
)
|
||||||
assert len(messages) > 0, "Should be able to send message after concurrent requests complete"
|
assert len(messages) > 0, "Should be able to send message after concurrent requests complete"
|
||||||
|
|
||||||
|
def test_agent_direct_list_messages(self, client: Letta, agent):
|
||||||
|
"""Test listing messages using agent ID as conversation_id."""
|
||||||
|
# First send a message via agent-direct mode
|
||||||
|
list(
|
||||||
|
client.conversations.messages.create(
|
||||||
|
conversation_id=agent.id,
|
||||||
|
messages=[{"role": "user", "content": "Test message for listing"}],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# List messages using agent ID
|
||||||
|
messages_page = client.conversations.messages.list(conversation_id=agent.id)
|
||||||
|
messages = list(messages_page)
|
||||||
|
|
||||||
|
# Should have messages (at least system + user + assistant)
|
||||||
|
assert len(messages) >= 3, f"Expected at least 3 messages, got {len(messages)}"
|
||||||
|
|
||||||
|
# Verify we can find our test message
|
||||||
|
user_messages = [m for m in messages if hasattr(m, "message_type") and m.message_type == "user_message"]
|
||||||
|
assert any("Test message for listing" in str(m.content) for m in user_messages), "Should find our test message"
|
||||||
|
|
||||||
|
def test_agent_direct_cancel(self, client: Letta, agent):
|
||||||
|
"""Test canceling runs using agent ID as conversation_id."""
|
||||||
|
from letta.settings import settings
|
||||||
|
|
||||||
|
# Skip if run tracking is disabled
|
||||||
|
if not settings.track_agent_run:
|
||||||
|
pytest.skip("Run tracking disabled - skipping cancel test")
|
||||||
|
|
||||||
|
# Start a background request that we can cancel
|
||||||
|
try:
|
||||||
|
# Send a message in background mode
|
||||||
|
stream = client.conversations.messages.create(
|
||||||
|
conversation_id=agent.id,
|
||||||
|
messages=[{"role": "user", "content": "Background message to cancel"}],
|
||||||
|
background=True,
|
||||||
|
)
|
||||||
|
# Consume a bit of the stream to ensure it started
|
||||||
|
next(iter(stream), None)
|
||||||
|
|
||||||
|
# Cancel using agent ID
|
||||||
|
result = client.conversations.cancel(conversation_id=agent.id)
|
||||||
|
|
||||||
|
# Should return results (may be empty if run already completed)
|
||||||
|
assert isinstance(result, dict), "Cancel should return a dict of results"
|
||||||
|
except Exception as e:
|
||||||
|
# If no active runs, that's okay - the run may have completed quickly
|
||||||
|
if "No active runs" not in str(e):
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
class TestConversationDelete:
|
class TestConversationDelete:
|
||||||
"""Tests for the conversation delete endpoint."""
|
"""Tests for the conversation delete endpoint."""
|
||||||
|
|||||||
Reference in New Issue
Block a user