feat: add support for default conversation via --conv default (#580)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-17 20:06:36 -08:00
committed by GitHub
parent 5f5c0df18e
commit f2b242cdc5
7 changed files with 256 additions and 68 deletions

View File

@@ -102,11 +102,16 @@ function prepareMessageHistory(messages: Message[]): Message[] {
}
/**
* Fetch messages in descending order (newest first) and reverse to get chronological.
* This gives us the most recent N messages in chronological order.
* Sort messages chronologically (oldest first) by date.
* The API doesn't guarantee order, so we must sort explicitly.
*/
function reverseToChronological(messages: Message[]): Message[] {
return [...messages].reverse();
function sortChronological(messages: Message[]): Message[] {
return [...messages].sort((a, b) => {
// All message types have 'date' field
const dateA = a.date ?? "";
const dateB = b.date ?? "";
return new Date(dateA).getTime() - new Date(dateB).getTime();
});
}
/**
@@ -130,7 +135,17 @@ export async function getResumeData(
let inContextMessageIds: string[] | null | undefined;
let messages: Message[];
if (conversationId) {
// Use conversations API for explicit conversations,
// use agents API for "default" or no conversationId (agent's primary message history)
const useConversationsApi = conversationId && conversationId !== "default";
if (process.env.DEBUG) {
console.log(
`[DEBUG] getResumeData: conversationId=${conversationId}, useConversationsApi=${useConversationsApi}, agentId=${agent.id}`,
);
}
if (useConversationsApi) {
// Get conversation to access in_context_message_ids (source of truth)
const conversation = await client.conversations.retrieve(conversationId);
inContextMessageIds = conversation.in_context_message_ids;
@@ -148,9 +163,7 @@ export async function getResumeData(
return {
pendingApproval: null,
pendingApprovals: [],
messageHistory: reverseToChronological(
backfill.getPaginatedItems(),
),
messageHistory: sortChronological(backfill.getPaginatedItems()),
};
}
return {
@@ -176,7 +189,7 @@ export async function getResumeData(
})
: null;
messages = backfillPage
? reverseToChronological(backfillPage.getPaginatedItems())
? sortChronological(backfillPage.getPaginatedItems())
: [];
// Find the approval_request_message variant if it exists
@@ -218,25 +231,16 @@ export async function getResumeData(
messageHistory: prepareMessageHistory(messages),
};
} else {
// Legacy: fall back to agent messages (no conversation ID)
// Use agent messages API for "default" conversation or when no conversation ID
// (agent's primary message history without explicit conversation isolation)
inContextMessageIds = agent.message_ids;
if (!inContextMessageIds || inContextMessageIds.length === 0) {
debugWarn(
"check-approval",
"No in-context messages (legacy) - no pending approvals",
"No in-context messages (default/agent API) - no pending approvals",
);
if (isBackfillEnabled()) {
const messagesPage = await client.agents.messages.list(agent.id, {
limit: MESSAGE_HISTORY_LIMIT,
order: "desc",
});
return {
pendingApproval: null,
pendingApprovals: [],
messageHistory: reverseToChronological(messagesPage.items),
};
}
// No in-context messages = empty default conversation, don't show random history
return {
pendingApproval: null,
pendingApprovals: [],
@@ -252,14 +256,22 @@ export async function getResumeData(
}
const retrievedMessages = await client.messages.retrieve(lastInContextId);
// Fetch message history separately for backfill (desc then reverse for last N chronological)
// Fetch message history for backfill using conversation_id=default
// This filters to only the default conversation's messages (like the ADE does)
const messagesPage = isBackfillEnabled()
? await client.agents.messages.list(agent.id, {
limit: MESSAGE_HISTORY_LIMIT,
order: "desc",
conversation_id: "default", // Key: filter to default conversation only
})
: null;
messages = messagesPage ? reverseToChronological(messagesPage.items) : [];
messages = messagesPage ? sortChronological(messagesPage.items) : [];
if (process.env.DEBUG && messagesPage) {
console.log(
`[DEBUG] agents.messages.list(conversation_id=default) returned ${messagesPage.items.length} messages`,
);
}
// Find the approval_request_message variant if it exists
const messageToCheck =
@@ -288,7 +300,7 @@ export async function getResumeData(
} else {
debugWarn(
"check-approval",
`Last in-context message ${lastInContextId} not found via retrieve (legacy)`,
`Last in-context message ${lastInContextId} not found via retrieve (default/agent API)`,
);
}

View File

@@ -18,6 +18,10 @@ export const STREAM_REQUEST_START_TIME = Symbol("streamRequestStartTime");
/**
* Send a message to a conversation and return a streaming response.
* Uses the conversations API for proper message isolation per session.
*
* For the "default" conversation (agent's primary message history without
* an explicit conversation object), pass conversationId="default" and
* provide agentId in opts. This uses the agents messages API instead.
*/
export async function sendMessageStream(
conversationId: string,
@@ -25,7 +29,7 @@ export async function sendMessageStream(
opts: {
streamTokens?: boolean;
background?: boolean;
// add more later: includePings, request timeouts, etc.
agentId?: string; // Required when conversationId is "default"
} = { streamTokens: true, background: true },
// TODO: Re-enable once issues are resolved - disabled retries were causing problems
// Disable SDK retries by default - state management happens outside the stream,
@@ -37,17 +41,47 @@ export async function sendMessageStream(
const requestStartTime = isTimingsEnabled() ? performance.now() : undefined;
const client = await getClient();
const stream = await client.conversations.messages.create(
conversationId,
{
messages: messages,
streaming: true,
stream_tokens: opts.streamTokens ?? true,
background: opts.background ?? true,
client_tools: getClientToolsFromRegistry(),
},
requestOptions,
);
let stream: Stream<LettaStreamingResponse>;
if (process.env.DEBUG) {
console.log(
`[DEBUG] sendMessageStream: conversationId=${conversationId}, useAgentsRoute=${conversationId === "default"}`,
);
}
if (conversationId === "default") {
// Use agents route for default conversation (agent's primary message history)
if (!opts.agentId) {
throw new Error(
"agentId is required in opts when using default conversation",
);
}
stream = await client.agents.messages.create(
opts.agentId,
{
messages: messages,
streaming: true,
stream_tokens: opts.streamTokens ?? true,
background: opts.background ?? true,
client_tools: getClientToolsFromRegistry(),
},
requestOptions,
);
} else {
// Use conversations route for explicit conversations
stream = await client.conversations.messages.create(
conversationId,
{
messages: messages,
streaming: true,
stream_tokens: opts.streamTokens ?? true,
background: opts.background ?? true,
client_tools: getClientToolsFromRegistry(),
},
requestOptions,
);
}
// Attach start time to stream for TTFT calculation in drainStream
if (requestStartTime !== undefined) {

View File

@@ -1729,6 +1729,7 @@ export default function App({
stream = await sendMessageStream(
conversationIdRef.current,
currentInput,
{ agentId: agentIdRef.current },
);
} catch (preStreamError) {
// Check if this is a pre-stream approval desync error

View File

@@ -79,7 +79,7 @@ export const AgentInfoBar = memo(function AgentInfoBar({
<Text dimColor>{" "}</Text>
{isCloudUser && (
<Link
url={`https://app.letta.com/agents/${agentId}${conversationId ? `?conversation=${conversationId}` : ""}`}
url={`https://app.letta.com/agents/${agentId}${conversationId && conversationId !== "default" ? `?conversation=${conversationId}` : ""}`}
>
<Text>Open in ADE </Text>
</Link>

View File

@@ -224,6 +224,37 @@ export function ConversationSelector({
const client = clientRef.current || (await getClient());
clientRef.current = client;
// Fetch default conversation data (agent's primary message history)
// Only fetch on initial load (not when paginating)
let defaultConversation: EnrichedConversation | null = null;
if (!afterCursor) {
try {
const defaultMessages = await client.agents.messages.list(agentId, {
limit: 20,
order: "desc",
conversation_id: "default", // Filter to default conversation only
});
const defaultMsgItems = defaultMessages.items;
if (defaultMsgItems.length > 0) {
const defaultStats = getMessageStats(
[...defaultMsgItems].reverse(),
);
defaultConversation = {
conversation: {
id: "default",
agent_id: agentId,
created_at: new Date().toISOString(),
} as Conversation,
previewLines: defaultStats.previewLines,
lastActiveAt: defaultStats.lastActiveAt,
messageCount: defaultStats.messageCount,
};
}
} catch {
// If we can't fetch default messages, just skip showing it
}
}
const result = await client.conversations.list({
agent_id: agentId,
limit: FETCH_PAGE_SIZE,
@@ -276,7 +307,11 @@ export function ConversationSelector({
if (isLoadingMore) {
setConversations((prev) => [...prev, ...nonEmptyConversations]);
} else {
setConversations(nonEmptyConversations);
// Prepend default conversation to the list (if it has messages)
const allConversations = defaultConversation
? [defaultConversation, ...nonEmptyConversations]
: nonEmptyConversations;
setConversations(allConversations);
setPage(0);
setSelectedIndex(0);
}
@@ -448,6 +483,8 @@ export function ConversationSelector({
);
};
const isDefault = conv.id === "default";
return (
<Box key={conv.id} flexDirection="column" marginBottom={1}>
<Box flexDirection="row">
@@ -461,8 +498,9 @@ export function ConversationSelector({
bold={isSelected}
color={isSelected ? colors.selector.itemHighlighted : undefined}
>
{conv.id}
{isDefault ? "default" : conv.id}
</Text>
{isDefault && <Text dimColor> (agent's default conversation)</Text>}
{isCurrent && (
<Text color={colors.selector.itemCurrent}> (current)</Text>
)}

View File

@@ -75,6 +75,7 @@ export async function handleHeadlessCommand(
continue: { type: "boolean", short: "c" },
resume: { type: "boolean", short: "r" },
conversation: { type: "string" },
default: { type: "boolean" }, // Alias for --conv default
"new-agent": { type: "boolean" },
new: { type: "boolean" }, // Deprecated - kept for helpful error message
agent: { type: "string", short: "a" },
@@ -200,10 +201,22 @@ export async function handleHeadlessCommand(
// Resolve agent (same logic as interactive mode)
let agent: AgentState | null = null;
const specifiedAgentId = values.agent as string | undefined;
const specifiedConversationId = values.conversation as string | undefined;
let specifiedAgentId = values.agent as string | undefined;
let specifiedConversationId = values.conversation as string | undefined;
const useDefaultConv = values.default as boolean | undefined;
const shouldContinue = values.continue as boolean | undefined;
const forceNew = values["new-agent"] as boolean | undefined;
// Handle --default flag (alias for --conv default)
if (useDefaultConv) {
if (specifiedConversationId && specifiedConversationId !== "default") {
console.error(
"Error: --default cannot be used with --conversation (they're mutually exclusive)",
);
process.exit(1);
}
specifiedConversationId = "default";
}
const systemPromptPreset = values.system as string | undefined;
const systemCustom = values["system-custom"] as string | undefined;
const systemAppend = values["system-append"] as string | undefined;
@@ -214,8 +227,29 @@ export async function handleHeadlessCommand(
const sleeptimeFlag = (values.sleeptime as boolean | undefined) ?? undefined;
const fromAfFile = values["from-af"] as string | undefined;
// Handle --conv {agent-id} shorthand: --conv agent-xyz → --agent agent-xyz --conv default
if (specifiedConversationId?.startsWith("agent-")) {
if (specifiedAgentId && specifiedAgentId !== specifiedConversationId) {
console.error(
`Error: Conflicting agent IDs: --agent ${specifiedAgentId} vs --conv ${specifiedConversationId}`,
);
process.exit(1);
}
specifiedAgentId = specifiedConversationId;
specifiedConversationId = "default";
}
// Validate --conv default requires --agent
if (specifiedConversationId === "default" && !specifiedAgentId) {
console.error("Error: --conv default requires --agent <agent-id>");
console.error("Usage: letta --agent agent-xyz --conv default");
console.error(" or: letta --conv agent-xyz (shorthand)");
process.exit(1);
}
// Validate --conversation flag (mutually exclusive with agent-selection flags)
if (specifiedConversationId) {
// Exception: --conv default requires --agent
if (specifiedConversationId && specifiedConversationId !== "default") {
if (specifiedAgentId) {
console.error("Error: --conversation cannot be used with --agent");
process.exit(1);
@@ -531,13 +565,21 @@ export async function handleHeadlessCommand(
);
if (specifiedConversationId) {
// User specified a conversation to resume
try {
await client.conversations.retrieve(specifiedConversationId);
conversationId = specifiedConversationId;
} catch {
console.error(`Error: Conversation ${specifiedConversationId} not found`);
process.exit(1);
if (specifiedConversationId === "default") {
// "default" is the agent's primary message history (no explicit conversation)
// Don't validate - just use it directly
conversationId = "default";
} else {
// User specified an explicit conversation to resume - validate it exists
try {
await client.conversations.retrieve(specifiedConversationId);
conversationId = specifiedConversationId;
} catch {
console.error(
`Error: Conversation ${specifiedConversationId} not found`,
);
process.exit(1);
}
}
} else if (shouldContinue) {
// Try to resume the last conversation for this agent
@@ -547,17 +589,22 @@ export async function handleHeadlessCommand(
settingsManager.getGlobalLastSession();
if (lastSession && lastSession.agentId === agent.id) {
// Verify the conversation still exists
try {
await client.conversations.retrieve(lastSession.conversationId);
conversationId = lastSession.conversationId;
} catch {
// Conversation no longer exists, create new
const conversation = await client.conversations.create({
agent_id: agent.id,
isolated_block_labels: isolatedBlockLabels,
});
conversationId = conversation.id;
if (lastSession.conversationId === "default") {
// "default" is always valid - just use it directly
conversationId = "default";
} else {
// Verify the conversation still exists
try {
await client.conversations.retrieve(lastSession.conversationId);
conversationId = lastSession.conversationId;
} catch {
// Conversation no longer exists, create new
const conversation = await client.conversations.create({
agent_id: agent.id,
isolated_block_labels: isolatedBlockLabels,
});
conversationId = conversation.id;
}
}
} else {
// No matching session, create new conversation
@@ -818,9 +865,11 @@ export async function handleHeadlessCommand(
};
// Send the approval to clear the pending state; drain the stream without output
const approvalStream = await sendMessageStream(conversationId, [
approvalInput,
]);
const approvalStream = await sendMessageStream(
conversationId,
[approvalInput],
{ agentId: agent.id },
);
if (outputFormat === "stream-json") {
// Consume quickly but don't emit message frames to stdout
for await (const _ of approvalStream) {
@@ -875,7 +924,9 @@ export async function handleHeadlessCommand(
try {
while (true) {
const stream = await sendMessageStream(conversationId, currentInput);
const stream = await sendMessageStream(conversationId, currentInput, {
agentId: agent.id,
});
// For stream-json, output each chunk as it arrives
let stopReason: StopReasonType | null = null;
@@ -1822,7 +1873,9 @@ async function runBidirectionalMode(
}
// Send message to agent
const stream = await sendMessageStream(conversationId, currentInput);
const stream = await sendMessageStream(conversationId, currentInput, {
agentId: agent.id,
});
const streamProcessor = new StreamProcessor();

View File

@@ -380,6 +380,7 @@ async function main(): Promise<void> {
continue: { type: "boolean" }, // Deprecated - kept for error message
resume: { type: "boolean", short: "r" }, // Resume last session (or specific conversation with --conversation)
conversation: { type: "string", short: "C" }, // Specific conversation ID to resume (--conv alias supported)
default: { type: "boolean" }, // Alias for --conv default (use agent's default conversation)
"new-agent": { type: "boolean" }, // Force create a new agent
new: { type: "boolean" }, // Deprecated - kept for helpful error message
"init-blocks": { type: "string" },
@@ -461,10 +462,22 @@ async function main(): Promise<void> {
const shouldContinue = (values.continue as boolean | undefined) ?? false;
// --resume: Open agent selector UI after loading
const shouldResume = (values.resume as boolean | undefined) ?? false;
const specifiedConversationId =
let specifiedConversationId =
(values.conversation as string | undefined) ?? null; // Specific conversation to resume
const useDefaultConv = (values.default as boolean | undefined) ?? false; // --default flag
const forceNew = (values["new-agent"] as boolean | undefined) ?? false;
// Handle --default flag (alias for --conv default)
if (useDefaultConv) {
if (specifiedConversationId && specifiedConversationId !== "default") {
console.error(
"Error: --default cannot be used with --conversation (they're mutually exclusive)",
);
process.exit(1);
}
specifiedConversationId = "default";
}
// Check for deprecated --new flag
if (values.new) {
console.error(
@@ -477,6 +490,27 @@ async function main(): Promise<void> {
const initBlocksRaw = values["init-blocks"] as string | undefined;
const baseToolsRaw = values["base-tools"] as string | undefined;
let specifiedAgentId = (values.agent as string | undefined) ?? null;
// Handle --conv {agent-id} shorthand: --conv agent-xyz → --agent agent-xyz --conv default
if (specifiedConversationId?.startsWith("agent-")) {
if (specifiedAgentId && specifiedAgentId !== specifiedConversationId) {
console.error(
`Error: Conflicting agent IDs: --agent ${specifiedAgentId} vs --conv ${specifiedConversationId}`,
);
process.exit(1);
}
specifiedAgentId = specifiedConversationId;
specifiedConversationId = "default";
}
// Validate --conv default requires --agent
if (specifiedConversationId === "default" && !specifiedAgentId) {
console.error("Error: --conv default requires --agent <agent-id>");
console.error("Usage: letta --agent agent-xyz --conv default");
console.error(" or: letta --conv agent-xyz (shorthand)");
process.exit(1);
}
const specifiedAgentName = (values.name as string | undefined) ?? null;
const specifiedModel = (values.model as string | undefined) ?? undefined;
const systemPromptPreset = (values.system as string | undefined) ?? undefined;
@@ -613,7 +647,8 @@ async function main(): Promise<void> {
}
// Validate --conversation flag (mutually exclusive with agent-selection flags)
if (specifiedConversationId) {
// Exception: --conv default requires --agent
if (specifiedConversationId && specifiedConversationId !== "default") {
if (specifiedAgentId) {
console.error("Error: --conversation cannot be used with --agent");
process.exit(1);
@@ -1024,8 +1059,23 @@ async function main(): Promise<void> {
// =====================================================================
// TOP-LEVEL PATH: --conversation <id>
// Conversation ID is unique, so we can derive the agent from it
// (except for "default" which requires --agent flag, validated above)
// =====================================================================
if (specifiedConversationId) {
if (specifiedConversationId === "default") {
// "default" requires --agent (validated in flag preprocessing above)
// Use the specified agent directly, skip conversation validation
// TypeScript can't see the validation above, but specifiedAgentId is guaranteed
if (!specifiedAgentId) {
throw new Error("Unreachable: --conv default requires --agent");
}
setSelectedGlobalAgentId(specifiedAgentId);
setSelectedConversationId("default");
setLoadingState("assembling");
return;
}
// For explicit conversations, derive agent from conversation
try {
const conversation = await client.conversations.retrieve(
specifiedConversationId,