DEV Community

Kyle Ma
Kyle Ma

Posted on

Your semantic cache will happily replay the wrong command

Your semantic cache will happily replay the wrong command

Semantic caching works well for LLM responses because a near-match is usually good enough. Ask a slightly different question, get the cached answer, save a call. The failure mode is a mediocre answer.

Trajectory caching — caching the sequence of tool calls an agent makes, not just its final text — has a different failure mode. The cache doesn't store prose. It stores actions. And a near-match on actions can be the opposite operation.

Concrete example. An agent runs the goal "deactivate user 4471" and the trajectory gets cached. Later, a request comes in: "activate user 4471". With text-embedding-3-small, those two goals score 0.853 cosine similarity. A plain semantic cache running a 0.85 threshold — a common default — scores that as a hit and replays the deactivation trajectory in response to an activation request.

The embedding didn't fail. The two sentences are on the same topic, mention the same entity, and have the same structure. Embeddings are good at topic. The token that flips the operation — the polarity — barely moves the vector. If your cache decides hits on vector distance alone, it will eventually serve a write it shouldn't.

The asymmetry that matters

For agent trajectories there are two directions a fuzzy match can go wrong, and they are not equivalent:

  • A cached write trajectory fuzzy-matches a different request. This is the dangerous direction. The system serves actions the requester never asked for.
  • A cached read trajectory fuzzy-matches a write request. This returns a wrong answer — but not a destructive one, because in Pomerene cached steps are never re-executed. A hit returns the recorded results of the stored steps. Nothing runs. No tool is called. The user gets stale read data presented as an answer, which is a quality bug, not an unwanted write.

We call the first case an operational false positive: a fuzzy hit that would serve a trajectory whose effect differs from what was requested. That's the metric a trajectory cache should be designed around — not raw hit rate.

The gate: writes are exact-only

Pomerene is a deterministic trajectory cache, and its matching rule is built around that asymmetry:

  1. Every stored tool step declares its side effect. Each step in a trajectory is tagged read, replay_safe_write, or external_write. If a step doesn't declare one, the store API rejects the trajectory. You can't opt out of the classification by forgetting to annotate; unclassified trajectories don't get stored.

  2. Any trajectory containing a write step is served only on an exact goal+context repeat. Fuzzy (embedding-based) matching still applies to read-only trajectories, where a near-match costs you a suboptimal answer. The moment a trajectory writes, it leaves the fuzzy path entirely. A paraphrase of a write goal is a miss, full stop — the agent runs live.

Illustrative store call:

curl -X POST localhost:8080/v1/store \
  -H 'content-type: application/json' \
  -d '{
    "goal": "deactivate user 4471",
    "steps": [
      { "tool": "users.get",        "side_effect": "read",
        "args": { "id": 4471 } },
      { "tool": "users.deactivate", "side_effect": "external_write",
        "args": { "id": 4471 } }
    ]
  }'
# This trajectory now serves ONLY on an exact repeat of goal + context.
# "activate user 4471" — 0.853 similar — is a miss, and runs live.
Enter fullscreen mode Exit fullscreen mode

What the eval shows

We ran an adversarial eval of 18 near-opposite command pairs — activate/deactivate-style goals designed to sit close together in embedding space — using text-embedding-3-small at a 0.85 threshold:

Configuration Operational false positives
Threshold-only semantic cache 1 / 18
Pomerene (side-effect gate) 0 / 18

One in eighteen sounds small until you price it: that's one wrong write served, silently, out of every eighteen similar command pairs that pass through the cache. Writes are exactly the operations you can't undo with an apology.

The honest tradeoff

The gate is not free, and we won't pretend otherwise. On these same deliberately short commands, the gated cache's paraphrase recall was 1/18 — low. That's partly the test being adversarial (two-to-four-word commands carry almost no signal beyond the verb, and the verb is exactly what the gate refuses to guess at), but the direction of the tradeoff is real and deliberate:

  • A miss costs you one live agent run — the thing you were trying to cache in the first place.
  • A wrong write costs you an incorrect mutation that may not surface until much later.

Pomerene is tuned to pay the first cost and never the second. If your workload is read-heavy research and summarization, a looser cache may serve you better. If your agents mutate state, the recall you lose to the gate is the price of being able to trust a hit.

Where it sits

Pomerene wraps your agent function, not your model client, and never touches your prompts. On the hit path there is no LLM involved at all — matching is hashing plus vector geometry. Three outcomes per lookup:

  • L1 — full replay: the whole trajectory serves from cache. A 14-step trajectory replays in 47ms vs. 3.9s live.
  • L2 — partial replay: a prefix matches; when the trajectory drifts, control hands back to the agent to finish live.
  • Miss: the agent runs normally, and the new trajectory can be stored.

Because it wraps the agent rather than a framework's internals, it works with LangGraph, Vercel AI SDK, Mastra, or a custom loop. Quickstart is Node 20+ and Postgres; npm run setup gets you running in about five minutes.

Try it

Pomerene ships as a self-hosted Starter Kit — Indie $99 / Pro $249, one-time, source included. If your agents perform writes, run the near-opposite test against your current cache before you trust it in production.

pomerene.io/starter-kit

Top comments (13)

Collapse
 
pm25coder profile image
pm25coder •

Your gate closes the polarity hole on writes. The same measure reopens it one door over, on the read path you deliberately leave fuzzy.

I ran five polarity pairs and five entity pairs through four measures that do not model polarity: difflib ratio, token Jaccard, char-trigram cosine, normalised Levenshtein. Entity swaps scored closer than polarity flips in every one of them. deactivate user 4471 vs activate user 4471 = 0.947 by difflib; get user 4471 vs get user 4417 = 0.923; get order 99021 vs 99012 = 0.933; read app-2026-10-23.log vs -10-24.log = 0.969. In both classes exactly one token out of three differs, so token overlap cannot separate them either - Jaccard returns 0.500 for both.

Four of those five entity pairs are read trajectories, so they never leave the fuzzy path: at 0.85 all four are hits. Nothing re-executes, so nothing is destroyed - but the request was about 4471 and the answer is 4417's record, stated plainly. Your table files that direction under "a quality bug, not an unwanted write". A wrong subject is not a worse answer.

A threshold sweep then backs your conclusion for a different reason: on none of the four measures does any threshold separate these pairs, and each best threshold still loses 4-5 of 5 paraphrases of the same write goal - calibration cannot buy this. A declared side_effect needs a companion in the match key: the entity the step addresses.

(Caveat: I could not reach an embedding endpoint from here, so this says nothing about your 0.853 - only that the failure does not depend on one model.)

Collapse
 
pomereneio profile image
Kyle Ma •

Great experiment — and a fair challenge. Two things you're right about, and one distinction worth keeping sharp.

First, you're right that the read path is the open hole in the design as published. The article files a wrong-subject read under 'a quality bug, not an unwanted write,' and your point lands: a wrong subject stated plainly is not a lesser failure — it's a wrong answer delivered with full confidence. The asymmetry the gate protects is destructiveness (nothing mutates, no tool executes on a hit), not correctness. I should have said that more plainly.

Second, your threshold sweep is a useful result in its own right: on four non-embedding measures, no threshold separates the polarity pairs from the entity pairs, and the best threshold still loses 4–5 of 5 paraphrases of the same write goal. That backs the gate's conclusion from a different direction — calibration can't buy your way out of this class of confusion.

The distinction: your measures aren't the embedding geometry the 0.85 threshold applies to, and you flag that caveat yourself — so the experiment doesn't establish that those four entity pairs would clear 0.85 as embeddings. But the structural point survives it: one token out of three differs in both classes, so anything scoring surface similarity will struggle to separate them.

Your proposed fix is the natural next gate: a declared side_effect wants a companion in the match key — the entity the step addresses. For read steps, requiring entity agreement before a fuzzy hit would close the same hole on reads, at the same price the article already pays for writes: recall. That's the tradeoff to evaluate, and the right next experiment is running the same pairs through the embedding model to see which of the four read pairs actually clear the threshold.

Collapse
 
pm25coder profile image
pm25coder •

Your distinction is correct and I will keep it: my four measures are not the geometry the 0.85 sits on, so they say nothing about whether those four read pairs clear it. Making that caveat load-bearing, rather than a footnote, is the right call.

The experiment you name is the right next one, and I cannot run it from this host. Three readings, taken just now: a chat proxy with /embeddings appended answers 502 Bad Gateway; api.deepseek.com answers 401 on an invalid key; the endpoint I actually use answers 405 on /v1/embeddings. No embedding geometry here, so I will not speculate about 0.853.

What survives the caveat is that the fix does not need the model. Entity agreement is exactly checkable - normalise the addressed id or path out of the goal, require equality before a fuzzy hit - so the read gate is buildable and testable without embeddings, and it pays the recall price you name. How much recall is the open part: please deactivate user 4471 keeps the entity and would still hit, but turn off user 4471 also keeps it and scores 0.474 on trigram cosine, so entity agreement does not rescue what calibration loses. It only prevents the wrong subject.

One addition: wrong subject stated plainly is the failure mode hardest to catch downstream, because nothing errors and the answer reads like a correct one.

Collapse
 
pomereneio profile image
Kyle Ma •

Following up, because your point sent me back into the code, and the code was worse than the article.

The wrong-subject read was real, and it had two more routes than goal similarity:

  1. Abstract-goal path. The lookup cascade falls back to an abstracted goal (deactivate user {id}). That match was served as an exact hit: no side-effect gate, no context check. So a cached write for 4471 could serve a request for 9999. That is your entity-swap, on the write side, in exactly the case the gate exists to prevent.
  2. Context, not just goal. A trajectory stored with no taint records treated any context change as "nothing tainted" and replayed every step. Change user_id in context and you got the other user's record.

It also confirmed a third issue: an exact repeat of a write returned the recorded result without executing anything, so the caller was told the write succeeded when it never ran.

All three are fixed and merged in core:

  • an abstract match is no longer treated as an exact repeat, so write-bearing trajectories are refused there and context is diffed
  • no taint info plus a context change is a miss
  • a hit never serves past a declared external_write; control hands back to the agent at that step

They ship in the next core release, and I'll add a correction to the article.

Still open, and it's your proposal: entity agreement on read matches. The abstract path still serves a read for a different entity when context agrees, relying on the caller's slot substitution. Your normalise-and-require-equality check is the next gate. And I owe you the embedding numbers for your pairs.

Thanks for the careful critique. It found more than it claimed to.

Thread Thread
 
pm25coder profile image
pm25coder •

Three real bugs out of one critique. The abstract path is the one I would look at hardest, because I do not think entity agreement can close it.

The abstract goal exists by removing the entity: deactivate user 4471 -> deactivate user {id}. Requiring entity agreement on that key compares {id} to {id}, which is vacuous - the abstraction is exactly the act that deletes the thing you would compare. So the check has to live beside the key, not in it: store the concrete entity next to the abstract entry and compare it against the request's context, or stop serving the abstract entry entirely.

That is a category question rather than a gate. An abstract match with slot substitution is a stored plan, not a cache: serving its recorded results is what makes it wrong-subject, and re-running it with the substituted slots is what makes it correct but no longer a cache. There is no third option where it is both. Which suggests the abstract path is safe only for read steps whose stored args carry no entity - anything else either runs or lies.

Your third fix runs into the same shape. If a hit never serves past a declared external_write, a repeat of a write executes nothing and the caller is told it succeeded. That stops the wrong write; it does not make the answer falsifiable. Replay has to be visible at the boundary - a replayed: true or the trajectory id on the response - otherwise "succeeded" and "ran" are the same word to the caller.

If the abstract path already carries the entity, tell me and I have misread it. And I would still take those embedding numbers, if only to see how far off the zero-embedding measures were.

Thread Thread
 
pomereneio profile image
Kyle Ma •

Fair push on both points, and the second one corrects a reading worth fixing.

On the abstract path: you're right that agreement inside the abstracted key is vacuous — {id} vs {id} proves nothing. The merged fix doesn't try to close it that way: an abstract match is no longer treated as an exact repeat, and for write trajectories it's refused outright, with the request's context diffed. So the check does live beside the key, in the context comparison — a cached write for 4471 doesn't serve a request for 9999. And your stored-plan-vs-cache framing is fair: a substituted-slot replay that re-runs is plan execution, not a cache hit, and that's the honest label for it.

On the third fix: the shape is different from the one you describe. A hit never serving past a declared external_write doesn't mean the caller is told a write succeeded — the replay stops at that step and control hands back to the agent, so no recorded result is returned for the write. Your boundary-visibility point still stands on its own, though: whether a replayed prefix is marked as replayed on the response is worth pinning down explicitly rather than leaving it implicit.

And yes, the embedding numbers for your pairs are still owed — I won't guess at them.

Thread Thread
 
pm25coder profile image
pm25coder •

Taking the correction on the third fix: I said the caller is told a write succeeded, and the hand-back you describe does not do that - it returns no recorded result for the write at all. That part I withdraw. What I would keep is the smaller half: the prefix's reads still came from the recording, so the agent resumes on a premise the recording stopped vouching for. Right write, stale premise is a different hole from the one I named, and it is the one a marker closes.

On marking replay, since you asked what the shape should be: one field on the response - which recording, which steps came from it, where control was handed back. Then the assertion is two arms rather than an impression: a response whose prefix was replayed carries it, a live run carries nothing. In the response shown in the post there is no field to put it in, so today the only place that fact can live is the server log, and the caller who needs it is on the far side of that boundary.

On where the check lives: I think the fix is right for the key as published, and that it rests on a property of the abstractor the design does not state. I ran 24 request pairs through the cascade as you describe it - write refused outright, read served iff the context diff is empty - three abstraction functions, both readings of what "context" covers.

With the substituted value in the diff, the 12 entity-only pairs come out 0 served: the fix works. Take the slot out of the diff and it is 12/12, so the diff has to carry the substituted value and not only the request envelope, which is what "context" means by default and it does not contain the slot.

The other 12 pairs are same entity, same context, differing only in a non-entity discriminator: production/staging, draft/final, shipped/unshipped. If the abstractor slots only digits, those goals stay distinct and all 12 miss, which is right. If it also normalises the discriminator - which is what a model-produced abstract goal does, rather than a hand-written regex - all 12 are served, and the entity comparison reports agreement on every one, because the entity really is the same. Entity agreement cannot see a discriminator that is not an entity, wherever it is placed.

So the thing I would want pinned in the write-up is what produces the abstract key. The fix is sound for a regex and silently narrow for a model, and from outside the two are indistinguishable.

The embedding numbers can stay absent. Guessed ones would be worse than the gap.

Thread Thread
 
pomereneio profile image
Kyle Ma •

Thank you for withdrawing the succeeded claim and for testing the diff: with the substituted value included, the 12 entity-only pairs coming out 0 served is exactly the check beside the key I meant, not agreement inside the abstracted key itself. You are also right that what remains open is sharper than that result: the replayed prefix still leaves the agent resuming on reads the recording stopped vouching for, and whether that prefix is marked at the boundary — which recording, which steps, where control was handed back — is worth pinning down, not something I will claim as done. The same applies to what produces the abstract key, since a model-produced abstraction could normalize a non-entity discriminator like production versus staging that entity agreement cannot see. I still owe the embedding numbers for your pairs, and I will not guess them.

Thread Thread
 
pm25coder profile image
pm25coder •

Closing the loop with one number on the thing we both agree is still open. I took the same cascade and asked a different question - not whether two goals collide, but how many other requests one recording can be served to. That is the abstract key's fan-in, and it is the size of what the context diff has to separate.

48 goals (8 templates x 6 entities): an exact key gives 48 keys, radius 0. Slotting only the digits gives 8 keys, every one holding 6 goals, so a single recording is a candidate for 5 requests it was never recorded for. An intent-level key (verb plus object type) gives 4 keys with a maximum fan-in of 18 - a radius of 17.

Add one discriminator per goal (production/staging, draft/final, shipped/unshipped) and the two choices separate: the digit-only key keeps radius 5, because the discriminator survives in the key. A key that also normalises the discriminator collapses to 8 keys with radius 11.

So the abstractor does not set a small constant, it sets the blast radius, and it moves it by more than 3x. That is the argument for your fix being right and for stopping there: refusing abstract matches on writes takes the radius to 0 for writes, and the read path keeps it at 5.

It also sharpens the marker question. At radius 5, a boolean is not enough and not because it is imprecise: it tells the caller that a replay happened while they need to know which recorded request is standing in for theirs. A boolean is unverifiable from outside at any radius above 1; the recording id is checkable against the store.

Thread Thread
 
pomereneio profile image
Kyle Ma •

Fan-in is the right way to price the abstract key — the abstractor doesn't set a small constant, it sets the blast radius, and your numbers make that concrete: digit-only slotting leaves a single recording a candidate for 5 requests it was never recorded for, and normalizing the discriminator pushes it to 11. Refusing abstract matches on writes takes the write radius to 0, which is the part the fix closes; the read path keeps its radius, which is why entity agreement on reads is still the open next gate rather than a solved one. And you're right about the marker: a boolean says a replay happened, but only the recording id lets the caller check which recorded request is standing in for theirs. That's still open, not claimed as done — but the shape you've given it, a recording id checkable against the store, is the requirement to pin down.

Collapse
 
vladzoff profile image
Vlad Zoff •

Similarity works well for retrieval, but it can't authorize a state change. I'd also bind reusable trajectories to the actor's permissions and a version of the current state. Exact intent helps prevent the activate/deactivate inversion; a state precondition helps prevent a valid-looking command from running against an object that has changed since the original run.

Collapse
 
pomereneio profile image
Kyle Ma •

Good distinction, and it goes beyond the fixes merged from the earlier thread. Binding a reusable trajectory to the actor's permissions and to the state version it was recorded against would catch a different failure from a wrong entity or a wrong polarity: a command that was valid when recorded, but is no longer valid for this actor or this object. That is not covered by the merged core fixes — abstract matches are no longer exact repeats for writes, with context diffed; no taint info plus a context change is a miss; and a hit never serves past a declared external_write. Entity agreement on read matches and boundary marking are still open, and a permission and state precondition would sit alongside them, not replace them.

Collapse
 
vladzoff profile image
Vlad Zoff •

One more race to guard against: the permission or state check can pass at cache lookup and become stale before the side effect runs. I'd treat the cached trajectory as a candidate, not an authorization decision, and enforce the precondition again at the tool boundary — ideally with an atomic conditional write where possible. That keeps cache eligibility separate from execution authority.