From f7cf0e02cf3b19c692b5e6e739b3767479687694 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Sun, 25 Jan 2026 20:47:34 -0800 Subject: [PATCH] fix: fix agents limit exceeded error + allow deletions in /agents (#678) Co-authored-by: Letta --- src/cli/components/AgentSelector.tsx | 182 +++++++++++++++++++++++---- src/cli/helpers/errorFormatter.ts | 29 +++++ 2 files changed, 188 insertions(+), 23 deletions(-) diff --git a/src/cli/components/AgentSelector.tsx b/src/cli/components/AgentSelector.tsx index f1a1737..7b17b89 100644 --- a/src/cli/components/AgentSelector.tsx +++ b/src/cli/components/AgentSelector.tsx @@ -24,6 +24,10 @@ interface AgentSelectorProps { type TabId = "pinned" | "letta-code" | "all"; +type ViewState = + | { type: "list" } + | { type: "deleteConfirm"; agent: AgentState; agentId: string }; + interface PinnedAgentData { agentId: string; agent: AgentState | null; @@ -156,6 +160,11 @@ export function AgentSelector({ const [searchInput, setSearchInput] = useState(""); const [activeQuery, setActiveQuery] = useState(""); + // Delete confirmation state + const [viewState, setViewState] = useState({ type: "list" }); + const [deleteConfirmInput, setDeleteConfirmInput] = useState(""); + const [deleteLoading, setDeleteLoading] = useState(false); + // Load pinned agents const loadPinnedAgents = useCallback(async () => { setPinnedLoading(true); @@ -354,10 +363,13 @@ export function AgentSelector({ } }, [allLoadingMore, allHasMore, allCursor, fetchListAgents, activeQuery]); - // Pagination calculations - Pinned - const pinnedTotalPages = Math.ceil(pinnedAgents.length / DISPLAY_PAGE_SIZE); + // Pagination calculations - Pinned (filter out 404 agents) + const validPinnedAgents = pinnedAgents.filter((p) => p.agent !== null); + const pinnedTotalPages = Math.ceil( + validPinnedAgents.length / DISPLAY_PAGE_SIZE, + ); const pinnedStartIndex = pinnedPage * DISPLAY_PAGE_SIZE; - const pinnedPageAgents = pinnedAgents.slice( + const pinnedPageAgents = validPinnedAgents.slice( pinnedStartIndex, pinnedStartIndex + DISPLAY_PAGE_SIZE, ); @@ -424,6 +436,34 @@ export function AgentSelector({ } }, [activeQuery]); + // Handle agent deletion + const handleDeleteAgent = useCallback(async () => { + if (viewState.type !== "deleteConfirm") return; + const { agent, agentId } = viewState; + const expectedName = agent.name || agentId.slice(0, 12); + + if (deleteConfirmInput !== expectedName) return; + + setDeleteLoading(true); + try { + const client = clientRef.current || (await getClient()); + clientRef.current = client; + await client.agents.delete(agentId); + + // Reset state and refresh tabs + setViewState({ type: "list" }); + setDeleteConfirmInput(""); + // Reload pinned and invalidate cached tabs + loadPinnedAgents(); + setLettaCodeLoaded(false); + setAllLoaded(false); + } catch { + // Stay on confirmation screen on error + } finally { + setDeleteLoading(false); + } + }, [viewState, deleteConfirmInput, loadPinnedAgents]); + useInput((input, key) => { // CTRL-C: immediately cancel if (key.ctrl && input === "c") { @@ -431,6 +471,30 @@ export function AgentSelector({ return; } + // Handle delete confirmation view + if (viewState.type === "deleteConfirm") { + // Always allow Esc to back out (even during deletion) + if (key.escape) { + setViewState({ type: "list" }); + setDeleteConfirmInput(""); + return; + } + + // Disable all other input while deleting + if (deleteLoading) return; + + if (key.return) { + handleDeleteAgent(); + } else if (key.backspace || key.delete) { + setDeleteConfirmInput((prev) => prev.slice(0, -1)); + } else if (input && !key.ctrl && !key.meta) { + setDeleteConfirmInput((prev) => prev + input); + } + return; + } + + // List view handlers below + // Tab key cycles through tabs if (key.tab) { const currentIndex = TABS.findIndex((t) => t.id === activeTab); @@ -441,8 +505,6 @@ export function AgentSelector({ if (currentLoading) return; - // For pinned tab, use pinnedPageAgents.length to include "not found" entries - // For other tabs, use currentAgents.length const maxIndex = activeTab === "pinned" ? pinnedPageAgents.length - 1 @@ -541,14 +603,6 @@ export function AgentSelector({ setAllSelectedIndex(0); } } - // NOTE: "D" for unpin all disabled - too destructive without confirmation - // } else if (activeTab === "pinned" && (input === "d" || input === "D")) { - // const selected = pinnedPageAgents[pinnedSelectedIndex]; - // if (selected) { - // settingsManager.unpinBoth(selected.agentId); - // loadPinnedAgents(); - // } - // } } else if (activeTab === "pinned" && (input === "p" || input === "P")) { // Unpin from current scope (pinned tab only) const selected = pinnedPageAgents[pinnedSelectedIndex]; @@ -560,6 +614,33 @@ export function AgentSelector({ } loadPinnedAgents(); } + } else if (input === "d" || input === "D") { + // Delete agent - open confirmation + let selectedAgent: AgentState | null = null; + let selectedAgentId: string | null = null; + + if (activeTab === "pinned") { + const selected = pinnedPageAgents[pinnedSelectedIndex]; + if (selected?.agent) { + selectedAgent = selected.agent; + selectedAgentId = selected.agentId; + } + } else if (activeTab === "letta-code") { + selectedAgent = lettaCodePageAgents[lettaCodeSelectedIndex] ?? null; + selectedAgentId = selectedAgent?.id ?? null; + } else { + selectedAgent = allPageAgents[allSelectedIndex] ?? null; + selectedAgentId = selectedAgent?.id ?? null; + } + + if (selectedAgent && selectedAgentId) { + setViewState({ + type: "deleteConfirm", + agent: selectedAgent, + agentId: selectedAgentId, + }); + setDeleteConfirmInput(""); + } } else if (input === "n" || input === "N") { // Create new agent onCreateNewAgent?.(); @@ -688,9 +769,62 @@ export function AgentSelector({ ); }; + // Render delete confirmation view + const renderDeleteConfirm = () => { + if (viewState.type !== "deleteConfirm") return null; + const { agent, agentId } = viewState; + const displayName = agent.name || agentId.slice(0, 12); + const inputMatches = deleteConfirmInput === displayName; + + return ( + <> + + + {" "}Are you sure you want to delete{" "} + {displayName}? + + {" "}This action can not be undone. + + + + {"> "} + + {deleteConfirmInput || "(type the agent's name)"} + + + + + + {" "} + {deleteLoading + ? "Deleting... · Esc cancel" + : inputMatches + ? "Enter to delete · Esc cancel" + : "Esc cancel"} + + + + ); + }; + // Calculate horizontal line width const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10)); + // If in delete confirmation view, render that instead of the list + if (viewState.type === "deleteConfirm") { + return ( + + {/* Command header */} + {`> ${command}`} + {solidLine} + + + + {renderDeleteConfirm()} + + ); + } + return ( {/* Command header */} @@ -741,7 +875,7 @@ export function AgentSelector({ {/* Empty state */} {!currentLoading && - ((activeTab === "pinned" && pinnedAgents.length === 0) || + ((activeTab === "pinned" && validPinnedAgents.length === 0) || (activeTab === "letta-code" && !lettaCodeError && lettaCodeAgents.length === 0) || @@ -753,13 +887,15 @@ export function AgentSelector({ )} {/* Pinned tab content */} - {activeTab === "pinned" && !pinnedLoading && pinnedAgents.length > 0 && ( - - {pinnedPageAgents.map((data, index) => - renderPinnedItem(data, index, index === pinnedSelectedIndex), - )} - - )} + {activeTab === "pinned" && + !pinnedLoading && + validPinnedAgents.length > 0 && ( + + {pinnedPageAgents.map((data, index) => + renderPinnedItem(data, index, index === pinnedSelectedIndex), + )} + + )} {/* Letta Code tab content */} {activeTab === "letta-code" && @@ -787,7 +923,7 @@ export function AgentSelector({ {/* Footer */} {!currentLoading && - ((activeTab === "pinned" && pinnedAgents.length > 0) || + ((activeTab === "pinned" && validPinnedAgents.length > 0) || (activeTab === "letta-code" && !lettaCodeError && lettaCodeAgents.length > 0) || @@ -800,7 +936,7 @@ export function AgentSelector({ : activeTab === "letta-code" ? `Page ${lettaCodePage + 1}${lettaCodeHasMore ? "+" : `/${lettaCodeTotalPages || 1}`}${lettaCodeLoadingMore ? " (loading...)" : ""}` : `Page ${allPage + 1}${allHasMore ? "+" : `/${allTotalPages || 1}`}${allLoadingMore ? " (loading...)" : ""}`; - const hintsText = `Enter select · ↑↓ navigate · ←→ page · Tab switch${activeTab === "pinned" ? " · P unpin" : " · Type to search"}${onCreateNewAgent ? " · N new" : ""} · Esc cancel`; + const hintsText = `Enter select · ↑↓ ←→ navigate · Tab switch · D delete${activeTab === "pinned" ? " · P unpin" : ""}${onCreateNewAgent ? " · N new" : ""} · Esc cancel`; return ( diff --git a/src/cli/helpers/errorFormatter.ts b/src/cli/helpers/errorFormatter.ts index b9fb274..5c8eebd 100644 --- a/src/cli/helpers/errorFormatter.ts +++ b/src/cli/helpers/errorFormatter.ts @@ -91,6 +91,23 @@ function getResourceLimitMessage(e: APIError): string | undefined { return undefined; } +/** + * Check if the error is an agent limit error (429 with agents-limit-exceeded) + */ +function isAgentLimitError(e: APIError): boolean { + if (e.status !== 429) return false; + + const errorBody = e.error; + if (errorBody && typeof errorBody === "object") { + if ("reasons" in errorBody && Array.isArray(errorBody.reasons)) { + if (errorBody.reasons.includes("agents-limit-exceeded")) { + return true; + } + } + } + return false; +} + /** * Check if the error is a credit exhaustion error (402 with not-enough-credits) */ @@ -152,6 +169,18 @@ export function formatErrorDetails( return `You've hit your usage limit. ${resetInfo}. View usage: ${LETTA_USAGE_URL}`; } + // Check for agent limit error (free tier agent count limit) + if (isAgentLimitError(e)) { + const { billingTier } = getErrorContext(); + + if (billingTier?.toLowerCase() === "free") { + return `You've reached the agent limit (3) for the Free Plan. Delete agents at: ${LETTA_AGENTS_URL}\nOr upgrade to Pro for unlimited agents at: ${LETTA_USAGE_URL}`; + } + + // Fallback for paid tiers (shouldn't normally hit this, but just in case) + return `You've reached your agent limit. Delete agents at: ${LETTA_AGENTS_URL}\nOr check your plan at: ${LETTA_USAGE_URL}`; + } + // Check for resource limit error (e.g., "You have reached your limit for agents") const resourceLimitMsg = getResourceLimitMessage(e); if (resourceLimitMsg) {