fix: fix agents limit exceeded error + allow deletions in /agents (#678)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user