fix: fix agents limit exceeded error + allow deletions in /agents (#678)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-25 20:47:34 -08:00
committed by GitHub
parent 55033e8580
commit f7cf0e02cf
2 changed files with 188 additions and 23 deletions

View File

@@ -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<ViewState>({ 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 (
<>
<Box flexDirection="column" marginBottom={1}>
<Text>
{" "}Are you sure you want to delete{" "}
<Text bold>{displayName}</Text>?
</Text>
<Text color="red">{" "}This action can not be undone.</Text>
</Box>
<Box flexDirection="row">
<Text color={colors.selector.itemHighlighted}>{"> "}</Text>
<Text dimColor={!deleteConfirmInput}>
{deleteConfirmInput || "(type the agent's name)"}
</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>
{" "}
{deleteLoading
? "Deleting... · Esc cancel"
: inputMatches
? "Enter to delete · Esc cancel"
: "Esc cancel"}
</Text>
</Box>
</>
);
};
// 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 (
<Box flexDirection="column">
{/* Command header */}
<Text dimColor>{`> ${command}`}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
{renderDeleteConfirm()}
</Box>
);
}
return (
<Box flexDirection="column">
{/* 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 && (
<Box flexDirection="column">
{pinnedPageAgents.map((data, index) =>
renderPinnedItem(data, index, index === pinnedSelectedIndex),
)}
</Box>
)}
{activeTab === "pinned" &&
!pinnedLoading &&
validPinnedAgents.length > 0 && (
<Box flexDirection="column">
{pinnedPageAgents.map((data, index) =>
renderPinnedItem(data, index, index === pinnedSelectedIndex),
)}
</Box>
)}
{/* 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 (
<Box flexDirection="column">

View File

@@ -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) {