Settings

Language

Why Your AI Agent Keeps Losing Its Memory (And How We Fixed It)

L
LemonData
ยทFebruary 28, 2026ยท29 views
#ai-agents#memory#fallback#architecture#lemonclaw
Why Your AI Agent Keeps Losing Its Memory (And How We Fixed It)

Why Your AI Agent Keeps Losing Its Memory (And How We Fixed It)

Your AI agent just had a 30-minute conversation with a user. They discussed project requirements, shared preferences, made decisions. Then the user types /new to start a fresh session.

The agent tries to consolidate that conversation into long-term memory. The LLM call fails. Rate limit. Timeout. Or the model returns text instead of calling the required tool.

The memory is gone. Thirty minutes of context, evaporated.

This happens more often than you'd think. We tracked it across our LemonClaw instances: memory consolidation had a ~15% failure rate on any single model. For a feature that's supposed to be invisible infrastructure, that's unacceptable.

How Other Frameworks Handle This (They Don't)

Most AI agent frameworks treat memory consolidation as a simple LLM call. If it works, great. If it doesn't, the memory is lost.

OpenClaw, the most popular open-source agent framework, uses the same model for consolidation as for conversation. A Claude Sonnet call that costs $0.003 and takes 8+ seconds, just to summarize a chat the user will never see. When that call fails (rate limit, timeout, model error), the framework logs a warning and moves on. The user's context is gone.

nanobot, another popular framework, has the same architecture. One model, one attempt, no fallback. The consolidation function doesn't even have a timeout. A slow upstream (Cloudflare 524 errors are common) blocks the entire session until the connection drops.

Neither framework separates consolidation from the main model. Neither has fallback logic for memory operations. Neither distinguishes between "the API call failed" and "the API call succeeded but the model didn't do what we asked."

These aren't edge cases. With 15% failure rates on any single model, a framework running 100 consolidations per day loses memory on 15 of them. Over a week, that's 105 conversations where the agent forgets everything.

The Problem Is Deeper Than Retry Logic

The obvious fix is retry with exponential backoff. We had that. It handles transient HTTP errors fine:

# Retry loop: 1s โ†’ 2s โ†’ 4s backoff
for attempt in range(3):
    try:
        response = await acompletion(**kwargs)
        return await self._collect_stream(response)
    except (RateLimitError, APIConnectionError) as e:
        await asyncio.sleep(RETRY_DELAYS[attempt])

This catches 429s and network blips. But two failure modes slip through:

Failure mode 1: The model can't do tool calling. Some models, especially smaller ones running on fast inference engines, occasionally fail to generate valid function calls on complex prompts. The API returns a 200 with a ServiceUnavailableError wrapped inside a MidStreamFallbackError. Your retry logic sees an exception, retries the same model, gets the same error.

Failure mode 2: The model "succeeds" but doesn't call the tool. The LLM returns a perfectly valid response. HTTP 200. No errors. But instead of calling save_memory with structured data, it writes a plain text summary. Your retry engine considers this a success. The consolidation function checks for tool calls, finds none, and gives up.

The second failure mode is the insidious one. The transport layer thinks everything worked. The business layer knows it didn't. No amount of HTTP-level retries will fix a model that doesn't understand your tool schema.

Dual-Layer Fallback Architecture

We solved this with two independent fallback loops operating at different levels:

User sends /new
    โ”‚
    โ–ผ
consolidate() โ”€โ”€โ”€ Business Layer Fallback
    โ”‚               "Did the model call save_memory?"
    โ”‚               No โ†’ try next model in chain
    โ”‚
    โ–ผ
_chat_with_retry() โ”€โ”€โ”€ Transport Layer Fallback
    โ”‚                    HTTP errors โ†’ exponential backoff
    โ”‚                    All retries exhausted โ†’ walk fallback chain
    โ”‚
    โ–ผ
MODEL_MAP fallback chain:
    llama-3.3-70b  โ”€$0.59/Mโ”€โ†’  qwen3-32b  โ”€$0.29/Mโ”€โ†’  llama-4-scout  โ”€$0.11/Mโ”€โ†’  gpt-4.1-mini  โ”€โ†’  claude-haiku
    (394 TPS)                   (662 TPS)                (594 TPS)                  (reliable)        (last resort)

Layer 1 handles transport failures. Layer 2 handles business logic failures. The fallback chain is shared between both layers, defined once in a central catalog.

This is a fundamentally different approach from retry-the-same-model. When a model fails to call a tool, retrying it with the same prompt rarely helps. Switching to a different model with different weights and different tool calling behavior does.

The Model Catalog: One Source of Truth

Every model in our catalog has an optional fallback field pointing to the next model to try:

@dataclass(frozen=True)
class ModelEntry:
    id: str
    label: str
    tier: str
    description: str
    fallback: str | None = None
    hidden: bool = False  # Hidden from user-facing /model list

MODEL_CATALOG = [
    # User-visible models (16 models users can switch between)
    ModelEntry("claude-sonnet-4-6", "Claude Sonnet 4.6", "standard",
               "Recommended", fallback="claude-sonnet-4-5"),
    ModelEntry("gpt-4.1-mini", "GPT-4.1 Mini", "economy",
               "Stable tool calling", fallback="claude-haiku-4-5"),

    # Hidden consolidation models (internal use only)
    ModelEntry("llama-3.3-70b-versatile", "Llama 3.3 70B (Groq)", "economy",
               "394 TPS", fallback="qwen3-32b", hidden=True),
    ModelEntry("qwen3-32b", "Qwen3 32B (Groq)", "economy",
               "662 TPS", fallback="llama-4-scout-17b-16e-instruct", hidden=True),
    # ...
]

The hidden=True flag keeps internal models out of the user-facing /model command while still participating in fallback chains. Users see 16 models they can switch between. The system uses 19. The three hidden models exist solely for background tasks like memory consolidation, where speed and cost matter more than conversational quality.

This catalog is the single source of truth for all model routing. Adding a new model to the fallback chain means adding one line. No config files to sync, no environment variables to update, no deployment scripts to modify.

Transport Layer: Chained Fallback with Cycle Detection

The retry engine walks the fallback chain using a visited set to prevent infinite loops:

async def _chat_with_retry(self, kwargs, original_model):
    # Phase 1: Exponential backoff on the primary model
    for attempt in range(3):
        try:
            response = await acompletion(**kwargs)
            return await self._collect_stream(response)
        except (RateLimitError, APIConnectionError, APIError) as e:
            await asyncio.sleep(RETRY_DELAYS[attempt])
        except AuthenticationError:
            return LLMResponse(content="API key invalid.", finish_reason="error")

    # Phase 2: Walk the fallback chain
    visited = {original_model}
    current = original_model
    while True:
        entry = MODEL_MAP.get(current)
        if not entry or not entry.fallback or entry.fallback in visited:
            break
        current = entry.fallback
        visited.add(current)

        # Resolve correct gateway for this model
        gw = self._resolve_gateway_for_model(current)
        resolved = self._resolve_model(current, gateway=gw)
        fb_kwargs = {**kwargs, "model": resolved}

        # Fix api_base for the target model's protocol
        if gw and gw.default_api_base:
            fb_kwargs["api_base"] = gw.default_api_base

        try:
            response = await acompletion(**fb_kwargs)
            return await self._collect_stream(response)
        except Exception:
            continue  # Try next in chain

    return LLMResponse(content="Service unavailable.", finish_reason="error")

The visited set is critical. Without it, a chain like Aโ†’Bโ†’A would loop forever. With it, the engine tries each model exactly once.

Gateway resolution matters too. Different models need different API formats. Claude models route through an Anthropic-format gateway (no /v1 suffix). GPT models route through an OpenAI-compatible gateway (with /v1). Groq models use yet another endpoint. The fallback engine resolves the correct gateway for each model in the chain, preventing protocol mismatches like sending Anthropic requests to an OpenAI endpoint.

This is a detail that most frameworks ignore entirely. They assume all models speak the same protocol. In production, with 19 models across 4 different API formats, that assumption breaks immediately.

Business Layer: Tool Call Verification

The consolidation function adds its own fallback loop on top:

async def consolidate(self, session, provider, model, **kwargs):
    visited = set()
    current_model = model

    while current_model and current_model not in visited and len(visited) <= 3:
        visited.add(current_model)

        response = await asyncio.wait_for(
            provider.chat(messages=messages, tools=SAVE_MEMORY_TOOL, model=current_model),
            timeout=30,
        )

        if response.has_tool_calls:
            # Success: extract and save memory
            args = response.tool_calls[0].arguments
            self.write_long_term(args["memory_update"])
            self.append_history(args["history_entry"])
            return True

        # Model didn't call the tool โ€” try next in chain
        entry = MODEL_MAP.get(current_model)
        next_model = entry.fallback if entry else None
        if next_model and next_model not in visited:
            current_model = next_model
            continue

        return False  # No more fallbacks

    return False

This catches the case where _chat_with_retry returns a successful response (HTTP 200, valid content) but the model didn't use the tool. The consolidation function checks for has_tool_calls, and if missing, moves to the next model in the chain.

The timeout wrapper (asyncio.wait_for) also triggers fallback. If a model takes longer than 30 seconds (common with Cloudflare 524 errors on slow upstreams), the function catches TimeoutError and tries the next model instead of blocking the user's session indefinitely.

Why Groq for Consolidation

Memory consolidation is a background task. The user doesn't see the output. They just need it to work. This makes it a perfect candidate for fast, cheap models.

Most frameworks use the same expensive model for everything. If you're running Claude Sonnet for conversation, you're also running Claude Sonnet for memory consolidation. That's $3/M input tokens and 8+ seconds per consolidation, for a task that produces output no human ever reads.

We decoupled consolidation from the conversation model entirely. The conversation uses whatever model the user selected. Consolidation uses a dedicated chain of Groq-hosted models:

Model Speed Input Cost Output Cost
llama-3.3-70b-versatile 394 TPS $0.59/M $0.79/M
qwen3-32b 662 TPS $0.29/M $0.59/M
llama-4-scout-17b-16e 594 TPS $0.11/M $0.34/M
gpt-4.1-mini (previous) ~150 TPS $0.40/M $1.60/M

The primary model (llama-3.3-70b) consolidates a 60-message session in ~5 seconds. The previous default (gpt-4.1-mini) took 8+ seconds. Cost per consolidation dropped from ~$0.003 to ~$0.001.

The tradeoff: Groq models have less reliable tool calling on complex prompts. That's exactly why the dual-layer fallback exists. When llama-3.3-70b fails to call the tool, qwen3-32b picks up. If that fails too, llama-4-scout tries. If all three Groq models fail, gpt-4.1-mini handles it with near-100% tool calling reliability.

In production, we see the primary model succeed ~85% of the time. The chain reaches gpt-4.1-mini in less than 2% of consolidations. Total failure rate: effectively zero.

Production Results

We deployed this to two LemonClaw instances and tested with real Telegram conversations.

First deployment (single-layer fallback only):

Memory consolidation (archive_all): 56 messages
llama-3.3-70b-versatile โ†’ "Failed to call a function"
Falling back โ†’ qwen3-32b
qwen3-32b: LLM did not call save_memory, skipping
โ†’ "Memory archival failed, session not cleared."

The transport layer caught the first failure and fell back. But qwen3-32b returned text without calling the tool. Single-layer fallback couldn't handle this. This is the exact scenario that every other framework would silently lose memory on.

Second deployment (dual-layer fallback):

Memory consolidation (archive_all): 60 messages
model=llama-3.3-70b-versatile โ†’ success
Memory consolidation done: 60 messages remaining

Same model, same message volume. This time it worked on the first try. The intermittent nature of the tool calling failure is exactly why you need a fallback chain rather than a single backup model.

When the primary model does fail, the chain catches it:

llama-3.3-70b โ†’ tool call failed
โ†’ consolidate() fallback โ†’ qwen3-32b
โ†’ qwen3-32b didn't call tool
โ†’ consolidate() fallback โ†’ llama-4-scout
โ†’ llama-4-scout didn't call tool
โ†’ consolidate() fallback โ†’ gpt-4.1-mini
โ†’ gpt-4.1-mini called save_memory โœ“
Memory consolidation done

Four models tried, memory saved. The user sees "New session started." and has no idea any of this happened.

The Architecture Gap

LemonClaw's memory system vs. the alternatives, feature by feature:

Capability Typical AI Agent Framework LemonClaw
Consolidation model Same as conversation (expensive, slow) Independent model chain, Groq-accelerated
Failure handling Log warning, lose memory Dual-layer fallback, 5 models deep
Transport fallback Retry same model 3x Chained fallback across different models
Business logic fallback None Tool call verification + model switching
Timeout protection None (Cloudflare 524 blocks session) asyncio.wait_for(timeout=30) + fallback
Session truncation None (context grows forever) Truncate old messages after consolidation
History search None HISTORY.md rolling window, grep-searchable
Internal models Not supported hidden=True for system-only models
Cycle prevention Not needed (no chains) visited set prevents Aโ†’Bโ†’A loops
Gateway resolution Single API format assumed Per-model gateway with protocol detection

Every row in this table represents a production failure we either experienced ourselves or observed in other frameworks' issue trackers. The dual-layer fallback, the hidden model catalog, the per-model gateway resolution, the timeout-triggered fallback: none of these exist in OpenClaw, nanobot, or any other open-source agent framework we've examined.

What We Learned

"Request succeeded" is not "task succeeded." Generic retry engines operate at the HTTP level. They can't know that a 200 response with valid JSON is actually a failure because the model didn't use the tool you asked for. Business-critical operations need their own success criteria and their own fallback logic.

Small models fail differently than large models. Large models (GPT-4.1, Claude Sonnet) almost always call tools when asked. Small models on fast inference engines sometimes generate valid-looking responses that ignore the tool schema entirely. This isn't a bug you can fix with prompt engineering. It's a capability gap that requires architectural mitigation.

Test with production data, not synthetic data. Our initial test with 6 synthetic messages passed on every model. The real 60-message session with tool call history, timestamps, and mixed languages failed on two out of three Groq models. The complexity of real data exposes failure modes that clean test data never will.


LemonClaw is an open-source AI agent framework with built-in multi-model routing, persistent memory, and 10+ chat platform integrations. The entire dual-layer fallback system described here ships in the open-source release. Run it on your own server: github.com/hedging8563/lemonclaw

Need 300+ AI models through one API key? lemondata.cc provides unified access to OpenAI, Anthropic, Google, DeepSeek, Groq, and more.

Share: