diff --git a/src/agent/check-approval.ts b/src/agent/check-approval.ts index 5fdc268..4d27176 100644 --- a/src/agent/check-approval.ts +++ b/src/agent/check-approval.ts @@ -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)`, ); } diff --git a/src/agent/message.ts b/src/agent/message.ts index de9f64b..636d3e3 100644 --- a/src/agent/message.ts +++ b/src/agent/message.ts @@ -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; + + 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) { diff --git a/src/cli/App.tsx b/src/cli/App.tsx index c24bfbf..d65591d 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -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 diff --git a/src/cli/components/AgentInfoBar.tsx b/src/cli/components/AgentInfoBar.tsx index ee2f571..57c413a 100644 --- a/src/cli/components/AgentInfoBar.tsx +++ b/src/cli/components/AgentInfoBar.tsx @@ -79,7 +79,7 @@ export const AgentInfoBar = memo(function AgentInfoBar({ {" "} {isCloudUser && ( Open in ADE ↗ diff --git a/src/cli/components/ConversationSelector.tsx b/src/cli/components/ConversationSelector.tsx index 337ff43..0325968 100644 --- a/src/cli/components/ConversationSelector.tsx +++ b/src/cli/components/ConversationSelector.tsx @@ -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 ( @@ -461,8 +498,9 @@ export function ConversationSelector({ bold={isSelected} color={isSelected ? colors.selector.itemHighlighted : undefined} > - {conv.id} + {isDefault ? "default" : conv.id} + {isDefault && (agent's default conversation)} {isCurrent && ( (current) )} diff --git a/src/headless.ts b/src/headless.ts index 996d25e..9a99230 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -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 "); + 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(); diff --git a/src/index.ts b/src/index.ts index ab77fb9..1c3ca77 100755 --- a/src/index.ts +++ b/src/index.ts @@ -380,6 +380,7 @@ async function main(): Promise { 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 { 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 { 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 "); + 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 { } // 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 { // ===================================================================== // TOP-LEVEL PATH: --conversation // 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,