← Blog

What leaves the machine when your agent remembers

Sovantica8 min read

Engrava 0.6.0 and the network: what a default install sends, and why auto_embed: false stops embedding on writes while a search still sends non-blank query text to a configured provider unless you supply a query_vector.

Engrava’s auto_embed field defaults to False and governs automatic embedding on writes only; it does not govern query embedding, so with a hosted provider configured a search still sends non-blank query text to that provider whenever you did not supply an explicit query_vector.

I know that because I replaced socket.connect and socket.create_connection with raising stubs to confirm something I thought I already knew. The write completed with no connection attempted. The search attempted a connection to port 443.

That’s the kind of thing “doesn’t phone home” hides, because the phrase covers two claims of very different sizes. The small one is that the library never calls its own vendor - no usage ping, no licence check, no update poll. That’s a property of the code and you can settle it quickly. The big one is that nothing you store ever leaves your machine, and that one is a property of your configuration rather than of the package. Engrava has an embeddings path: with a reachable hosted provider configured, an auto-embedded write sends the thought’s essence and content, and a search sends non-blank query text whenever no explicit query_vector is supplied.

So here is the second claim written out for Engrava 0.6.0, with the conditions attached, precisely enough that you can check it instead of taking my word. Everything below is about the Python library on PyPI; the MCP server is a separate package and I’ve flagged the one place the answer differs.

The plain install

pip install engrava pulls five runtime dependencies: pydantic, aiosqlite, numpy, click and PyYAML (pyproject.toml at v0.6.0). No third-party HTTP client is among them. The providers that need one sit behind extras - embeddings-openai and embeddings-ollama bring httpx, embeddings-hf brings huggingface_hub, embeddings-local brings sentence-transformers and torch.

That’s a weaker statement than “it cannot make a request”, and I’d rather give you the weaker true one. Python can reach the network with the standard library alone, and this package does exactly that in one place: the LongMemEval benchmark loader uses urllib.request to fetch a dataset from huggingface.co. It sits outside the default import graph - import engrava does not pull urllib.request into the process, while importing engrava.benchmarks.longmemeval.dataset_loader does; I checked sys.modules both ways. But it is in the package, and an article about outbound calls that skipped it would be doing the thing it is complaining about.

For the memory path itself, here is the check rather than the assertion. Take a config whose only substantive line is where the file lives:

database:
  path: ./agent.db

Then, in a process where socket.connect and socket.create_connection have been replaced with raising stubs, open the store, write two thoughts and run a hybrid search. It completes and returns both, ranked. The embeddings section parses as None, no provider object is constructed, and nothing attempts a connection.

That test is worth nothing if it cannot fail, so here is the counter-control: the same denial harness, with the openai-compatible provider configured and auto_embed=False. The write completed with no connection attempted. The recall, with no supplied query vector, attempted connects to port 443 at addresses that resolve from api.openai.com. Same harness, opposite result, which is what makes the first run evidence rather than an absence of activity.

Engrava 0.6.0 accepts four provider names in engrava.yaml, and on this question they are not interchangeable.

sentence-transformer encodes in your own process, so the text itself does not go out - but the weights have to be present first, and they are not shipped with the package. On a box with an empty cache and HF_HUB_OFFLINE=1, loading all-MiniLM-L12-v2 fails with “We couldn’t connect to ‘https://huggingface.co’ to load the files, and couldn’t find them in the cached files”. That traffic is model-weight retrieval rather than your memories, and it comes from sentence-transformers rather than from Engrava; in the empty-cache probe the weights had to be made available before local encoding could begin.

ollama sends the text to whatever base_url names, which defaults to http://localhost:11434. That default is on your box, and the field is caller-settable, so it is also the field that can end up pointing somewhere else.

openai-compatible sends the text to whatever base_url names too - the difference is the default, https://api.openai.com/v1. It will talk to a local OpenAI-shaped endpoint if you give it one.

huggingface sends the text to the hosted Hugging Face Inference API through huggingface_hub; its provider constructor takes no base_url, so that endpoint is not caller-settable through this configuration.

One detail is worth more than it looks. Write provider: openai-compatible with no base_url and the parsed config holds base_url=None - the endpoint is not in your YAML anywhere. The https://api.openai.com/v1 string is a fallback inside the provider-resolution function (config.py at v0.6.0). The file you’d open to see where your data goes doesn’t name the destination.

What travels is narrow and specific. With a reachable hosted provider configured, an auto-embedded write sends the thought’s essence and content to that provider - not its metadata, not its edges, not the rest of the store. With the same provider configured, a search sends non-blank query text to it, and only when no explicit query_vector was supplied.

The field that covers less than its name

auto_embed defaults to False and governs automatic embedding on writes only; it does not govern query embedding during search, which happens whenever a provider is configured, the query text is non-blank and no explicit query_vector was passed.

I had assumed the opposite - that auto_embed: false plus a remote provider meant an inert store - and the counter-control above is what corrected me.

The reason is one branch in the hybrid-search path. When the search has no vector to work with and a provider exists, it fills one in by embedding the query text. The branch tests for the provider. It does not test auto_embed. This is documented, which is somehow worse - the method’s own docstring says “when None and an embedding provider is configured, the query text is auto-embedded” - so nobody hid it and I still read the config field’s name instead of the search path.

It is a defensible design. Without an embedding and without a caller-supplied query_vector there is no vector axis to search on, so gating that on a write-side flag would quietly change what retrieval does. The name is the problem, not the behaviour.

The setting that does decide the question is the one above it: no embeddings section, or provider left unset, means no provider object is constructed and neither path has anything to call.

What that choice costs

For a hosted provider, two consequences are worth separating: how many provider calls you make, and what those calls disclose.

On the first, the shape is visible in the code even without a price list. With a provider configured, each create_thought or update_thought that re-embeds makes one provider call, and an auto-embedded bulk_store batch is embedded in one provider call. A search makes one call when a provider is configured, its query text is non-blank and no query_vector was supplied - and that call embeds the current query, not the stored records it ranks. So provider-call volume follows writes that re-embed, auto-embedded bulk batches, and searches meeting those conditions; it does not follow how much has accumulated in the store. I’m not attaching a figure to it. That depends on your model, your write volume and your endpoint, and a number I invented for an article would be worth nothing to you.

On the second, the two calls disclose different things. An auto-embedded write hands the provider the thought’s essence and content. A search with a provider configured, non-blank query text and no supplied query_vector hands it what your user just typed, in their own words - on a code path the write-side auto_embed field does not touch.

Going the other way, and what it takes with it

With no provider configured, search still runs with FTS5 keyword matching, recency, priority and graph signals fused into one score; the vector axis is skipped and its weight is redistributed across the remaining signals.

What that leaves out is worth being concrete about, because “keep it on the machine” is easy to say. FTS5 matches tokens, so without a vector for the query there is nothing in the fusion comparing the query’s meaning to a record’s. In the socket-denied run above, with two records stored, a query sharing content words with the Berlin-to-Lisbon record returned that record. A query asking the same thing in different words - “where does the person reside now” - returned both records and ranked the unrelated one first: FTS5 had no overlapping tokens to contribute, and the recency, priority and graph signals produced an ordering out of what was left.

Four routes keep the vector axis without sending text to a hosted service. Ollama bound to localhost. sentence-transformer, where encoding is in-process once the weights are present. openai-compatible pointed at an OpenAI-shaped endpoint on the same machine. Or vectors you compute yourself, supplied either as the query_vector argument on a search or through a CallbackProvider callable that itself makes no network calls - CallbackProvider wraps any (str) -> list[float] function, it is not among the four names the YAML provider field accepts, so you wire it in Python or not at all.

Where the MCP route differs

engrava-mcp 0.6.0 lists httpx among its default dependencies (its pyproject.toml), deliberately, so the HTTP-based providers work without a second install step. The “no third-party HTTP client is present” observation from the top of this article does not carry over to it.

The configuration behaves the same: the server reads the same engrava.yaml, resolves no embedding provider unless that file declares one, and logs at startup that vector search is inert when none is resolved.

Three questions for whatever store you’re on

These aren’t specific to us.

What does a base install put on disk? A dependency list with no HTTP client in it is a fact rather than a promise, which makes it a better starting point than a README. It is also not a proof

  • the standard library is always there, as the benchmark loader above shows - so read it as one piece of evidence and get a second one.

When you name a provider and no endpoint, where does the request go? Read the code that constructs the provider, not the config file. In Engrava 0.6.0 the openai-compatible default endpoint lives in resolve_embedding_provider(), and a YAML that omits base_url names no destination at all.

Which operations embed - writes, queries, or both? Ask about queries separately and explicitly. The write-side field is the one with the obvious name; in Engrava 0.6.0 the query path passes what your user typed to a configured provider whenever the query text is non-blank and no query_vector was supplied.

For our own answer to the third: at 0.6.0 the model calls in this package are embedding calls, through a provider you configured. Consolidation is not one of them - nothing under src/engrava/extensions/ references an embedding provider, and REFLECTION content is assembled from centroids, keyword unions, TF-IDF n-grams and regex entities. A guard test asserts that modules under that directory import no LLM SDK, and discovers the files it checks from the directory rather than from a list, so a module added later is covered without anyone remembering. Its scope is src/engrava/extensions/ and it deliberately does not cover src/engrava/embeddings/, which imports httpx and huggingface_hub because that is its job.

If you want the literal inventory, an exhaustive search for HTTP and HTTPS strings under src/engrava at tag v0.6.0 returns the OpenAI default, the Ollama default, the LongMemEval dataset URL and one URL-shaped example inside a comment about FTS5 tokenisation. Read that as the endpoints written down in the source, not as the whole outbound surface: it cannot show you an endpoint you supplied through config, or one constructed inside a dependency.

For the memory write and search paths, omitting the embeddings section is what prevents an embedding provider from being constructed at all:

database:
  path: ./agent.db
# no embeddings section: no provider is constructed, so neither the write path
# nor the search path has anything to call
pip install engrava

The code and the architecture behind it: github.com/sovantica/engrava

Tags: engrava · agents · memory · privacy

← Back to engrava.ai