SmartGateSmartGate

Agent Memory Architecture: What to Store and Retrieve

An agent memory architecture is three decisions rather than one library: what is written as a vector plus a payload, when a retrieval call is allowed to run, and what each of those two paths costs. SmartGate exposes the pattern as a single MCP tool, so the host owns the decision and the gateway owns the storage, the scoping and the accounting.

Short answer: An agent memory architecture is three decisions rather than one library: what is written as a vector plus a payload, when a retrieval call is allowed to run, and what each of those two paths costs. SmartGate exposes the pattern as a single MCP tool, so the host owns the decision and the gateway owns the storage, the scoping and the accounting.

Key takeaways

  • Memory is two paths, not one. A write pays for an embedding and a vector upsert; a read pays for a similarity search. They fail differently and they are optimized differently.
  • Scoping is the safety model. Retrieval only searches inside the scope you pass — user, agent or run — and a call that names no scope is rejected before it reaches the store.
  • Retrieval is a threshold, not a top-N list. A score floor plus a result cap is what stops a plausible-but-wrong memory from entering the context window.
  • Cost is a background-worker question. Whatever keeps memory alive between calls is a process; a worker that is not stopped keeps paying for nothing.
  • Exposure is one line in tools/list and one file per host. The interesting engineering is behind the endpoint, not in the config block.
  • Measure the hit rate before you buy storage. A store that answers 95% of retrievals needs better search, not more vectors.

Semantic retrieval: the read path over agent memory

Retrieval is the half of memory that a context window actually feels. An agent calls search with a query, a scope and a score floor, and gets back the memories that cleared the floor. The entry point is one method, and most of its arguments are decisions about scope:

# backend/smartgate/modules/memory/algorithm.py — source lines 1126–1237 (Memory.search)
def search(
        self,
        query: str,
        *,
        top_k: int = 20,
        filters: Optional[Dict[str, Any]] = None,
        threshold: float = 0.1,
        rerank: bool = False,
        **kwargs,
    ):
        """
        Searches for memories based on a query.

        Args:
            query (str): Query to search for.
            top_k (int, optional): Maximum number of results to return. Defaults to 20.
            filters (dict): Filter dict containing entity IDs and optional metadata filters.
                Must contain at least one of: user_id, agent_id, run_id.
                Example: filters={"user_id": "u1", "agent_id": "a1"}

                Enhanced metadata filtering with operators:
                - {"key": "value"} - exact match
                - {"key": {"eq": "value"}} - equals
                - {"key": {"ne": "value"}} - not equals
                - {"key": {"in": ["val1", "val2"]}} - in list
                - {"key": {"nin": ["val1", "val2"]}} - not in list
                - {"key": {"gt": 10}} - greater than
                - {"key": {"gte": 10}} - greater than or equal
                - {"key": {"lt": 10}} - less than
                - {"key": {"lte": 10}} - less than or equal
                - {"key": {"contains": "text"}} - contains text
                - {"key": {"icontains": "text"}} - case-insensitive contains
                - {"key": "*"} - wildcard match (any value)
                - {"AND": [filter1, filter2]} - logical AND
                - {"OR": [filter1, filter2]} - logical OR
                - {"NOT": [filter1]} - logical NOT
            threshold (float, optional): Minimum score for a memory to be included. Defaults to 0.1.
            rerank (bool, optional): Whether to rerank results. Defaults to False.

        Returns:
            dict: A dictionary containing the search results under a "results" key.
                  Example for v1.1+: `{"results": [{"id": "...", "memory": "...", "score": 0.8, ...}]}`

        Raises:
            ValueError: If filters doesn't contain at least one of user_id, agent_id, run_id,
                or if threshold/top_k values are invalid.
        """
        # Reject top-level entity params - must use filters instead
        _reject_top_level_entity_params(kwargs, "search")

        # Validate search parameters (before applying defaults)
        _validate_search_params(threshold=threshold, top_k=top_k)

        # Validate and trim entity IDs in filters
        effective_filters = filters.copy() if filters else {}
        if "user_id" in effective_filters:
            effective_filters["user_id"] = _validate_and_trim_entity_id(
                effective_filters["user_id"], "user_id"
            )
        if "agent_id" in effective_filters:
            effective_filters["agent_id"] = _validate_and_trim_entity_id(
                effective_filters["agent_id"], "agent_id"
            )
        if "run_id" in effective_filters:
            effective_filters["run_id"] = _validate_and_trim_entity_id(
                effective_filters["run_id"], "run_id"
            )
        if not any(key in effective_filters for key in ("user_id", "agent_id", "run_id")):
            raise ValueError(
                "filters must contain at least one of: user_id, agent_id, run_id. "
                "Example: filters={'user_id': 'u1'}"
            )

        limit = top_k

        # Apply enhanced metadata filtering if advanced operators are detected
        if self._has_advanced_operators(effective_filters):
            processed_filters = self._process_metadata_filters(effective_filters)
            # Remove logical/operator keys that have been reprocessed
            for logical_key in ("AND", "OR", "NOT"):
                effective_filters.pop(logical_key, None)
            for fk in list(effective_filters.keys()):
                if fk not in ("AND", "OR", "NOT", "user_id", "agent_id", "run_id") and isinstance(effective_filters.get(fk), dict):
                    effective_filters.pop(fk, None)
            effective_filters.update(processed_filters)

        keys, encoded_ids = process_telemetry_filters(effective_filters)
        capture_event(
            "mem0.search",
            self,
            {
                "limit": limit,
                "version": self.api_version,
                "keys": keys,
                "encoded_ids": encoded_ids,
                "sync_type": "sync",
                "threshold": threshold,
                "advanced_filters": bool(filters and self._has_advanced_operators(filters)),
            },
        )

        original_memories = self._search_vector_store(query, effective_filters, limit, threshold)

        # Apply reranking if enabled and reranker is available
        if rerank and self.reranker and original_memories:
            try:
                reranked_memories = self.reranker.rerank(query, original_memories, limit)
                original_memories = reranked_memories
            except Exception as e:
                logger.warning(f"Reranking failed, using original results: {e}")

        return {"results": original_memories}

Four things in this signature matter more than the others. Retrieval is always scoped: the filter object must name a user, an agent or a run, and the method raises rather than searching the whole store when it does not — a memory lookup with no scope is a data-leak path, not a convenience. The score floor defaults to a low value, which is the right default only when the caller has another way to judge relevance; a host that pastes results straight into a prompt should raise it. rerank is opt-in, because a cross-encoder pass costs a second model call on every retrieval. And the metadata operators are declared in the docstring rather than hidden in an enum, so a caller can see that in, contains and the logical joins exist before writing a filter that silently matches nothing. The write side that fills this index is the other half of the same story.

Memory management on the write path: vectors plus payloads

A memory is not a string in a table. It is a vector plus a payload, written together, and the payload is what retrieval filters on later:

# backend/smartgate/core/resources.py — source lines 125–139 (store_vectors)
async def store_vectors(self, collection: str, vectors, payloads, ids=None) -> int:
        from qdrant_client.models import PointStruct
        import uuid
        points = []
        for i, (vec, pay) in enumerate(zip(vectors, payloads)):
            points.append(PointStruct(
                id=ids[i] if ids else str(uuid.uuid4()),
                vector=vec,
                payload=pay,
            ))
        result = await self._client.upsert(
            collection_name=collection,
            points=points,
        )
        return len(points)

The write path is deliberately one round trip: build the points, upsert them in a single call, return how many landed. Three details decide how the store behaves under load. Ids are optional — without one the code generates a UUID, which means a retry after a timeout creates a second copy rather than overwriting the first, so a writer that needs idempotency has to supply ids. Payloads travel next to the vectors in the same object, which is what lets the read path filter on scope without a second lookup. And the return value is a count, not a list: the caller learns that eight points were written, not which ones, so verification belongs to a follow-up query rather than to the write. Everything a memory system spends on ingestion is spent here — on the embedding that produced the vector and on the upsert that stores it.

Prompt compression before a write is paid for

The cheapest memory is the one you never store. Compression sits in front of the write path for the same reason it sits in front of a long prompt: most agent traces contain repetition, boilerplate and tool output that no later retrieval will score highly. Cutting them first changes two bills at once. The embedding is charged per token, so a trace that is compressed to half its length costs half as much to ingest. The vector store is charged per point, so paragraphs merged into one summary point cost one retrieve slot instead of five.

The pattern that survives contact with real traces is pre-filtering rather than summarization. A goal string — the step intent or the user query — lets the writer drop paragraphs that cannot answer anything, and the gateway's context tool exposes exactly that as a purpose argument next to a target ratio. What must not happen is silent loss: a compressed trace that cannot be re-expanded is a support ticket, so keep the raw body where a run can still be audited and store the compressed form as the retrievable one. Compression is a write-time decision with a read-time consequence.

Prompt injection protection: a memory store is where secrets accumulate

Anything an agent reads can end up in memory, and memory is replayed into future prompts. That makes the store a second place a secret can leak from, and it makes output masking a small but real control:

# backend/tools/cursor_mcp_deep_test.py — source lines 47–50 (mask_key)
def mask_key(key: str) -> str:
    if len(key) <= 12:
        return "***"
    return f"{key[:8]}...{key[-4:]}"

The function is three lines and the policy is worth stating: keys shorter than the visible window collapse entirely to asterisks, and longer ones keep a prefix and a suffix so an operator can still tell two credentials apart in a log. Masking at the boundary is a backstop, not a redaction system — it protects the places the gateway prints, not every place a value can travel. The stronger control is upstream: never write a credential into a memory payload, because a payload is exactly what the read path filters on and returns. Treat the store as public within its scope and the masking helper becomes what it should be — the last check before a key reaches a transcript.

Cost optimization: stopping the worker that keeps paying

Reads and writes are per-call costs. The costs that surprise people are the ones attached to a process, and a memory service usually owns at least one background worker:

# backend/smartgate/core/events.py — source lines 49–56 (stop)
async def stop(self):
        if self._worker:
            self._worker.cancel()
            try:
                await self._worker
            except asyncio.CancelledError:
                pass
            logger.info("EventBus stopped")

Five lines and one behaviour: if a worker exists, cancel it and await the cancellation, swallowing the CancelledError the task raises as it unwinds. That last part is the difference between a clean shutdown and a shutdown that logs a stack trace nobody will read. The reason this belongs on a page about memory cost is arithmetic rather than style. A worker holds a connection, polls a queue, or sweeps expired points; every version of that continues to bill compute while the deployment is idle, and a fleet of idle replicas multiplies it. Shutting the worker down on the same path that closes the server is how the cost per deployment stays proportional to the traffic it actually served.

Claude Desktop memory config: the file the host reads

A tool is only reachable where the host has been told to look. Each host reads its own configuration file in its own shape, and the desktop client's block is the one people copy first:

# lib/connect/mcp-config-templates.ts — source lines 76–96 (buildClaudeDesktopMcpConfigJson)
function buildClaudeDesktopMcpConfigJson(
  mcpUrl: string,
  apiKeyPlaceholder: string = API_KEY_PLACEHOLDER,
): string {
  return JSON.stringify(
    {
      mcpServers: {
        smartgate: {
          type: "streamable-http",
          url: mcpUrl,
          headers: buildMcpAuthHeaders(
            apiKeyPlaceholder,
            PLATFORM_AGENT_ID["Claude Desktop"],
          ),
        },
      },
    },
    null,
    2,
  );
}

The generated block is the whole contract in fifteen lines: a server name, the streamable-http type, the endpoint URL and an authorization header. Two properties make it worth generating instead of documenting. The key placeholder travels as a named argument with a default, so the block a user pastes and the block the server tests are produced by the same function. And the agent-platform header is attached per host, which is what lets an audit row say Claude Desktop rather than unknown client — the difference between a log that answers questions and a log that only records that something happened.

Cursor setup for the memory tool: an install command instead of JSON

Not every host wants a file edit. Two of them will take a command that writes the configuration themselves, which removes the most common setup failure — editing a file the host never reads:

# lib/connect/mcp-config-templates.ts — source lines 213–225 (getMcpPlatformInstallCommand)
function getMcpPlatformInstallCommand(
  platform: McpPlatform,
  mcpUrl: string,
): string | null {
  switch (platform) {
    case "OpenClaw":
      return buildOpenClawMcpSetCommand(mcpUrl);
    case "Claude Desktop":
      return buildClaudeDesktopMcpAddCommand(mcpUrl);
    default:
      return null;
  }
}

The switch is small on purpose. OpenClaw and Claude Code each get their own command builder; every other platform returns nothing, because the honest answer for the rest is edit the JSON and a generated command would be a guess. Returning a null instead of a plausible-looking command is the kind of decision that shows up in support volume rather than in a demo: a user who is told there is no installer goes to the file path, while a user handed a wrong command goes to the issue tracker.

The roots of the client config: one filename per host

Whether the host takes a command or a file, the file has a name, and the name is per-platform:

# lib/connect/mcp-config-templates.ts — source lines 249–264 (mcpConfigFilename)
function mcpConfigFilename(platform: McpPlatform): string {
  switch (platform) {
    case "Claude Desktop":
      return "claude_desktop_config.json";
    case "Cursor":
      return "mcp.json";
    case "Windsurf":
      return "mcp_config.json";
    case "Hermes":
      return "mcp.json";
    case "OpenClaw":
      return "openclaw-smartgate-snippet.json";
    default:
      return "smartgate-mcp.json";
  }
}

Six cases and a default. Claude Desktop reads claude_desktop_config.json; Cursor and Hermes both read mcp.json; Windsurf reads mcp_config.json; OpenClaw takes a namespaced snippet; anything else falls back to a generic name. This is the layer where "the server works but the agent cannot see it" is actually decided — a correctly built MCP server is invisible to a client whose configuration was written into the wrong file. Deriving the name from the platform keeps the docs, the installer and the audit label agreeing about which file a given host owns.

Config JSON a generic host can paste: the memory tool it enables

The config is only interesting because of what it reaches. Once a host is pointed at the endpoint, the tool surface is whatever the server registered — and for memory that is one tool with an action parameter rather than a family of endpoints:

# backend/smartgate/api/mcp.py — source lines 109–126 (smart_fetch)
@server.tool(
        name="smart_fetch",
        description=TOOL_DESCRIPTIONS["smart_fetch"],
        annotations=tool_annotations("smart_fetch"),
    )
    async def smart_fetch(
        url: str = Field(description="Full HTTP or HTTPS URL to fetch."),
        timeout: int = Field(default=30, description="HTTP timeout in seconds."),
    ) -> str:
        _, registry = _app_state()
        module = registry.get("fetch")
        ctx = _tool_ctx()
        return await _run_with_audit(
            "fetch",
            ctx,
            module.process(ctx, url=url, timeout=timeout),
            {"url": url},
        )

The declaration is the contract the host reads: the tool name, a human-readable description, and annotations derived from the tool's risk. The body then resolves the module from the registry, builds a call context, and returns through one audited exit — so a memory call and a fetch call produce the same shape of audit record. That is what makes the config block worth generating per platform: the URL is the same for every host, and the accounting is the same for every tool.

Cursor memory config: one platform id, one filename, same block

The last piece of wiring is the platform identifier, and it is the only thing that differs between two hosts using the same transport:

# lib/connect/mcp-config-templates.ts — source lines 56–73 (buildCursorMcpConfigJson)
function buildCursorMcpConfigJson(
  mcpUrl: string,
  apiKeyPlaceholder: string = API_KEY_PLACEHOLDER,
): string {
  return JSON.stringify(
    {
      mcpServers: {
        smartgate: {
          type: "streamable-http",
          url: mcpUrl,
          headers: buildMcpAuthHeaders(apiKeyPlaceholder, PLATFORM_AGENT_ID.Cursor),
        },
      },
    },
    null,
    2,
  );
}

This is the Claude Desktop block with one substitution: the platform id comes from a typed lookup keyed by the platform name. Everything else — the server map, the type, the URL, the header building, the two-space JSON formatting — is shared code. That is the argument for generating client configuration rather than publishing a snippet per host: a snippet is a copy that goes stale in the next release, while a lookup keyed by platform changes in one place. For a memory tool the stake is retrieval itself: if the header is wrong, the calls still look like they ran, and the memory they wrote lands under a different platform than the one the audit view groups by.

Testing memory in an mcp playground

Every test of a memory system is a retrieval test, and the only way to run one is against a live server. That is the job an MCP playground or inspector does: connect to the endpoint, call tools/list to see what the server advertises, then call the memory tool with an add and follow it with a search that should return what you just wrote. Two assertions are worth automating rather than eyeballing. The first is that a search with no scope fails instead of returning everything — the rejection is the feature. The second is that a memory written by one key is invisible to another, because tenant isolation lives in the filter, not in the vector index. The tooling question is in MCP inspector alternatives.

JSON-RPC underneath the memory tool

Nothing on this page is special to memory at the protocol level. The transport is JSON-RPC 2.0 over one Streamable HTTP endpoint: initialize, then notifications/initialized, then tools/list, then a tools/call whose arguments are the memory operation and its fields. That has a practical consequence for anyone debugging retrieval. A memory call that returns a JSON-RPC error object never reached the store, while a memory call that returns a result with an empty list reached the store and found nothing above the threshold — two different bugs with two different fixes. Read the response envelope before you read the vector store, and the wire format is worth knowing in detail: the message format is where the error shapes are spelled out.


How SmartGate compares

What you store When retrieval runs What you pay
No memory Nothing; every run starts from the prompt Never The prompt token bill, every turn
Transcript in a vector store Raw turns, embedded as they arrive On every lookup, with no scope or floor Embeddings for the noise you also embed, plus a search per turn
Framework memory class in-process Objects in the agent's own process Until the process exits Nothing extra, until a restart empties it and the run forgets
SmartGate memory as a tool — one of seven smart_* tools Vector plus payload, scoped to user, agent or run On action=search, with a threshold and a result cap Metered like any other gateway call: 2M tokens a month on the free tier, 120 MCP requests a minute per key

The comparison is not about which store is faster. It is about where the three decisions live. If the memory class ships inside the agent, the scope rules, the score floor and the accounting are all in code the host can change; if it is a tool behind one endpoint, the same three decisions apply to every host at once. The middle column is the one people get wrong: retrieval that runs eagerly on every turn is the most common reason a memory-enabled agent costs more than the context window it was supposed to protect.

How to get started

  1. Decide what is worth storing. Take one agent run and mark the passages a future run would score highly. Everything else is embedding cost.
  2. Pick the scope. User, agent or run — the filter is mandatory, so this is the first decision, not a later hardening step.
  3. Attach the tool to one host. The per-platform bindings and the endpoint contract are on the connect page and the MCP endpoint page.
  4. Write, then search, then check the audit row. One add, one search, and one row in the logs view proving the call reached the gateway.
  5. Read the catalogue before you extend it. The tool arguments, including the memory action set, are catalogued in the MCP tools reference.

Start on the free tier — 2M tokens a month, all seven tools, 120 MCP requests a minute per key — and compare the plans on the pricing page once the retrieval hit rate is something you can measure.

Frequently Asked Questions

Limitations and what this does not do

  • A threshold is not a guarantee of relevance. A high-scoring memory can still be wrong, stale or written by a different workflow; the score floor reduces the volume of bad recalls, it does not judge them.
  • Scoping rules are only as strong as the caller. The read path requires a scope and the store filters on it; a host that passes a shared user id across tenants has still made every memory visible to everyone.
  • Nothing here deduplicates across runs. Two runs that observed the same fact write two points unless the writer checks first, and the write path does not check for you.
  • These are windows, not whole files. Each excerpt is a slice of a longer module, so the surrounding validation, telemetry and error handling are described rather than quoted.
  • Compression is lossy by design. A trace that is compressed before it is stored is not recoverable from the store, so raw audit material has to be kept somewhere else.
  • This is not a model host and it is not a retrieval-quality guarantee. It moves memory calls through one governed endpoint; it does not choose the embedding model for you.

Sources

Method note

The code in this article is not transcribed. Each block was cut directly out of the slice body returned by the SmartGate slice API and re-asserted byte-for-byte as a substring of that body before publication; the first line inside every fence records the file and the exact source lines. Symbols were pinned by whole-name containment (rule A level 2) and confirmed by the service's slot-proof endpoint before any prose was written. All nine excerpts are whole slice bodies. Sections 2:1 (prompt compression, no-slice), 4:1 (mcp playground, abstain) and 12:1 (json-rpc mcp, abstain) pinned no slice and are written from the public specification and the project's own endpoint behaviour; no code is quoted for them, and the abstentions are recorded rather than hidden.

Demand figures come from this project's own keyword run, recorded in research_brief.md and search_volume.json — the section phrases prompt compression (140), mcp playground (70), prompt injection protection (50), context window management (40), claude desktop mcp config (40), cursor mcp setup (40), mcp roots (30), mcp config json (30), cursor mcp config (30), llm cost optimization (30), json-rpc mcp (20) and semantic dedup (10), each with its competition band. The head term agent memory and its 1,300 monthly searches were measured in the same research pass.

Slice provenance

# SERP keyword Symbol File Source lines How it was pinned sha256(12)
1 semantic dedup Memory.search backend/smartgate/modules/memory/algorithm.py 1126–1237 rule A L2 → slot-proof aa50fd88cf6d
2 context window management store_vectors backend/smartgate/core/resources.py 125–139 rule A L2 → slot-proof d44a629fc212
3 prompt injection protection mask_key backend/tools/cursor_mcp_deep_test.py 47–50 rule A L2 → slot-proof 1ac4b9165914
4 llm cost optimization stop backend/smartgate/core/events.py 49–56 rule A L2 → slot-proof f4ddd0bc4e20
5 claude desktop mcp config buildClaudeDesktopMcpConfigJson lib/connect/mcp-config-templates.ts 76–96 rule A L2 → slot-proof 8f2a75a4ac53
6 cursor mcp setup getMcpPlatformInstallCommand lib/connect/mcp-config-templates.ts 213–225 rule A L2 → slot-proof 8ee797e82a2b
7 mcp roots mcpConfigFilename lib/connect/mcp-config-templates.ts 249–264 rule A L2 → slot-proof d60503ebfb68
8 mcp config json smart_fetch backend/smartgate/api/mcp.py 109–126 rule A L2 → slot-proof fdc9a4259783
9 cursor mcp config buildCursorMcpConfigJson lib/connect/mcp-config-templates.ts 56–73 rule A L2 → slot-proof 5ac3816f50b3

Every fenced block above was cut from the slice body and re-asserted against it byte-for-byte before publication. 9 of 12 sections pinned, 2 abstentions, 1 misses.