<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Abdullah Shahin</title>
    <description>The latest articles on DEV Community by Abdullah Shahin (@ashahin).</description>
    <link>https://dev.to/ashahin</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3956503%2F3f4fdd0b-b651-44f8-af9e-8a743d2ef2df.jpg</url>
      <title>DEV Community: Abdullah Shahin</title>
      <link>https://dev.to/ashahin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ashahin"/>
    <language>en</language>
    <item>
      <title>RAG reranking for production agents: four approaches, four failure modes</title>
      <dc:creator>Abdullah Shahin</dc:creator>
      <pubDate>Wed, 03 Jun 2026 12:30:05 +0000</pubDate>
      <link>https://dev.to/ashahin/rag-reranking-for-production-agents-four-approaches-four-failure-modes-48o</link>
      <guid>https://dev.to/ashahin/rag-reranking-for-production-agents-four-approaches-four-failure-modes-48o</guid>
      <description>&lt;p&gt;Most agents that "hallucinate" in production aren't actually hallucinating. The right context existed in the index. It just didn't make it to the top of the retrieval window.&lt;/p&gt;

&lt;p&gt;Reranking is the layer that decides whether your agent sees the answer or the noise. And the choice between reranker types shapes the failure mode you'll spend the next quarter debugging.&lt;/p&gt;

&lt;p&gt;I keep seeing teams pick a reranker the way you'd pick a vector DB — benchmark on a public dataset, ship the winner, move on. That works for retrieval-augmented chatbots. It doesn't work for agents, because the failure modes are different in a way the benchmarks don't surface — and because, as we learned the hard way building HiveIn, there is no single reranker that fits every retrieval call you make once you have more than one shape of query.&lt;/p&gt;

&lt;p&gt;The shape of the silent failure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;User → Agent&lt;/strong&gt;: &lt;em&gt;"Cancel my subscription."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent → Retrieval&lt;/strong&gt;: query embedding&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrieval → Agent&lt;/strong&gt;: top-5 = [pricing FAQ, tier comparison, upgrade flow, …]  &lt;em&gt;(the correct doc was in top-50 but didn't reach top-5)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent → Tool&lt;/strong&gt;: &lt;code&gt;cancel_account(wrong_target_id)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool → User&lt;/strong&gt;: &lt;em&gt;"Done."&lt;/em&gt;  &lt;em&gt;(wrong action executed — nobody knows yet)&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The right doc existed. The reranker didn't surface it. The agent acted anyway. That's the gap this article is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four approaches, and what each one breaks on
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Bi-encoder top-k, no rerank
&lt;/h3&gt;

&lt;p&gt;Just vector search. Cosine similarity over the query embedding and the document embeddings, take top-k, hand to the model.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;P50 latency:&lt;/strong&gt; ~30ms&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost:&lt;/strong&gt; near-zero per query&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality ceiling:&lt;/strong&gt; low&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Failure mode: &lt;em&gt;topically similar but query-mismatched&lt;/em&gt;. Bi-encoders score on topic overlap, not query-answer fit. "How do I cancel my subscription" pulls the pricing FAQ, the tier comparison page, and the upgrade flow — all topical, none answering the question. The model gets handed a context window full of &lt;em&gt;adjacent&lt;/em&gt; documents and either confabulates an answer that sounds right, or — if it's an agent — confidently fires the wrong tool against the wrong target.&lt;/p&gt;

&lt;p&gt;This is the default and it's almost always wrong for agent workloads. The latency is great. Everything else is a problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Cross-encoder rerankers (Cohere Rerank, BGE-reranker, Voyage rerank-2)
&lt;/h3&gt;

&lt;p&gt;Top-50 from the bi-encoder gets re-scored by a cross-encoder that processes (query, candidate) pairs jointly, attending across both. Top-5 goes to the model.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;P50 latency:&lt;/strong&gt; 100–300ms&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost:&lt;/strong&gt; per-token, scales with candidate count × candidate length&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality ceiling:&lt;/strong&gt; high&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Failure mode: &lt;em&gt;P99 latency and provider drift&lt;/em&gt;. The mean looks fine. The tail breaks SLAs because cross-encoders fundamentally can't batch across queries the way bi-encoders can — each query+candidate pair is its own forward pass. Hosted rerankers compound this with provider-side queueing during peak load.&lt;/p&gt;

&lt;p&gt;The other thing nobody tells you: when the provider quietly rolls a new reranker version, your offline eval suite doesn't catch it. Your top-1 results shift, your agent's behavior shifts, and the only signal is a slow drift in user complaints over the following week. Cross-encoders are a black box you don't own.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Late-interaction models (ColBERT, ColBERTv2, JaColBERT)
&lt;/h3&gt;

&lt;p&gt;Token-level similarity computed at retrieval time, using pre-computed per-token embeddings. Sits between bi-encoder and cross-encoder on the quality/latency curve.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;P50 latency:&lt;/strong&gt; ~50ms&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost:&lt;/strong&gt; at query time, cheap. At storage time, expensive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality ceiling:&lt;/strong&gt; high&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Failure mode: &lt;em&gt;index storage at scale&lt;/em&gt;. Per-token embeddings inflate your index size 10–30x versus a bi-encoder. Works great when your corpus is small or your infra budget is large. Becomes operationally untenable somewhere around 10M+ documents — the index stops fitting on the box you wanted it to fit on, and the next box up doubles your retrieval-tier cost.&lt;/p&gt;

&lt;p&gt;A lot of teams adopt ColBERT during prototyping when the corpus is small, then quietly migrate off it 18 months later when the cost curve catches up. If you can predict that trajectory in advance, skip it.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. LLM-as-reranker
&lt;/h3&gt;

&lt;p&gt;Take the top-N candidates from the bi-encoder, format them into a prompt, and ask a small LLM to rank them for the query. Sometimes this is GPT-4o-mini, sometimes a fine-tuned 1B model, sometimes the same model that's about to use the retrieved context.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;P50 latency:&lt;/strong&gt; 500ms–2s&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost:&lt;/strong&gt; tokens × N, plus the inference call itself&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality ceiling:&lt;/strong&gt; highest&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Failure mode: &lt;em&gt;stochastic ordering and cache hostility&lt;/em&gt;. Same query, same candidates, same model — the LLM can return a different ordering on a repeat call. You can lower the temperature, but you can't eliminate it without losing the reasoning that made you choose an LLM reranker in the first place. And caching is harder than the other approaches because the prompt encodes both the query and the candidates, so cache keys explode.&lt;/p&gt;

&lt;p&gt;LLM rerankers are the highest-ceiling option and the most expensive thing to operate. They're rarely the right default. They're often the right &lt;em&gt;escalation&lt;/em&gt; — used selectively when the cheaper rerankers are uncertain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision matrix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;P50 latency&lt;/th&gt;
&lt;th&gt;Quality ceiling&lt;/th&gt;
&lt;th&gt;Where it breaks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bi-encoder only&lt;/td&gt;
&lt;td&gt;30ms&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Query-intent mismatch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-encoder&lt;/td&gt;
&lt;td&gt;200ms&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;P99 tail, provider drift&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Late-interaction&lt;/td&gt;
&lt;td&gt;50ms&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Index storage at scale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM rerank&lt;/td&gt;
&lt;td&gt;1s&lt;/td&gt;
&lt;td&gt;Highest&lt;/td&gt;
&lt;td&gt;Stochasticity, cost, cache&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A reasonable default for an agent stack today: bi-encoder for the cheap recall pass, cross-encoder on the top-50, LLM rerank reserved for cases where the cross-encoder's top-1 score is ambiguous.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "score" actually means (and why it bites you)
&lt;/h2&gt;

&lt;p&gt;Before going further, the part that trips up almost every team building this for the first time: the number a reranker returns is not the same kind of number a vector search returns, and the numbers different rerankers return are not comparable to each other.&lt;/p&gt;

&lt;p&gt;A bi-encoder score is a cosine similarity (or a normalized dot product). It lives in roughly &lt;code&gt;[-1, 1]&lt;/code&gt;, the magnitudes drift by embedding model and normalization scheme, and it's a measurement of &lt;em&gt;topical similarity in the embedding space&lt;/em&gt; — not a probability that the chunk answers the query.&lt;/p&gt;

&lt;p&gt;A cross-encoder score depends entirely on which cross-encoder. Cohere returns a 0–1 calibrated relevance probability you can almost reason about across queries. BGE-reranker emits raw logits where the absolute number is meaningless — only the ranking &lt;em&gt;within a query&lt;/em&gt; matters; comparing scores across two different queries tells you nothing. Voyage normalizes differently again. ColBERT's score is the sum of max-similarity across token pairs, which is unbounded and scales with query length — a score of 8.4 for a four-token query means something completely different than 8.4 for a twenty-token query. LLM-as-reranker scores are usually fabrications the model attaches after the fact to justify the ordering it already chose; treat them as ordinal at best.&lt;/p&gt;

&lt;p&gt;Here's the same idea laid out as a reference:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scorer&lt;/th&gt;
&lt;th&gt;Range&lt;/th&gt;
&lt;th&gt;What the number actually means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bi-encoder cosine&lt;/td&gt;
&lt;td&gt;[-1.0, 1.0]&lt;/td&gt;
&lt;td&gt;Topical similarity in embedding space — &lt;em&gt;not&lt;/em&gt; a probability of relevance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cohere Rerank&lt;/td&gt;
&lt;td&gt;[0.0, 1.0]&lt;/td&gt;
&lt;td&gt;Calibrated relevance probability — almost comparable across queries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BGE-reranker&lt;/td&gt;
&lt;td&gt;Unbounded raw logits&lt;/td&gt;
&lt;td&gt;Only within-query ranking is meaningful — absolute number is noise&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Voyage rerank-2&lt;/td&gt;
&lt;td&gt;[0.0, 1.0]&lt;/td&gt;
&lt;td&gt;Normalized within Voyage's training distribution; not portable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ColBERT max-sim sum&lt;/td&gt;
&lt;td&gt;Unbounded&lt;/td&gt;
&lt;td&gt;Scales with query length — same number means different things at different lengths&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RRF fusion&lt;/td&gt;
&lt;td&gt;≈ 1/(k + rank)&lt;/td&gt;
&lt;td&gt;Tiny absolute values — high-confidence cutoffs are sub-0.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DBSF fusion&lt;/td&gt;
&lt;td&gt;Distribution-normalized&lt;/td&gt;
&lt;td&gt;High-confidence cutoffs are ~1.0+ — ~16x bigger number for the same idea&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM-as-reranker&lt;/td&gt;
&lt;td&gt;Whatever the model returned&lt;/td&gt;
&lt;td&gt;Post-hoc justification — treat as ordinal, not numeric&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And then there's hybrid retrieval, where you're already fusing dense and sparse scores via either Reciprocal Rank Fusion or Distribution-Based Score Fusion — and those two produce wildly different number ranges. We use both modes for different query shapes in HiveIn's retrieval layer, and the "high confidence" threshold we use for one is more than an order of magnitude different from the threshold for the other. Same retrieval pipeline. Same documents. Same idea of "the model is confident." Two totally different absolute numbers.&lt;/p&gt;

&lt;p&gt;The trap I keep seeing teams fall into is this: they swap a reranker, port over their old &lt;code&gt;if score &amp;gt; 0.7&lt;/code&gt; threshold, and silently lose half their gates because &lt;code&gt;0.7&lt;/code&gt; meant something completely different in the old scoring space. Or worse, they layer reranking onto an existing retrieval pipeline and start comparing the post-rerank score against thresholds that were calibrated for the raw retrieval score.&lt;/p&gt;

&lt;p&gt;The score's &lt;em&gt;distribution&lt;/em&gt; matters more than the absolute number. Distributions are per-(model, query-class). You cannot compare across rerankers, and you cannot compare across fusion modes. Anything you build on top of the score has to be calibrated against the specific pipeline producing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent-specific dimension nobody benchmarks
&lt;/h2&gt;

&lt;p&gt;For chatbots, reranking is a quality-vs-latency tradeoff and a sane default mostly works. For agents, there's a third axis the benchmarks don't measure: &lt;em&gt;how silent is the failure&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A chatbot user who gets a bad answer re-prompts. The damage is a moment of annoyance.&lt;/p&gt;

&lt;p&gt;An agent that gets bad retrieval makes a confident tool call against the wrong target. It fires the email to the wrong customer. It hits the API with the wrong record ID. It executes the workflow it thinks the retrieved doc was describing, and the retrieved doc was describing something else. The retrieval failure becomes a tool-execution incident, and by the time anyone notices, the action has already happened.&lt;/p&gt;

&lt;p&gt;The pattern that keeps showing up in the agent post-mortems I read, and in the traces we work through ourselves, is roughly this: when the top-1 reranker score sits below the corpus's historical 25th percentile for that query class, the probability that the next tool call is wrong rises sharply — often roughly double the baseline rate. The reranker already knew. The system just didn't let that knowledge inform the next decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we learned building HiveIn's retrieval layer
&lt;/h2&gt;

&lt;p&gt;The reason I'm convinced reranking is a policy problem and not a ranking problem is that we tried to make it a ranking problem first, and a single reranker stopped working almost immediately.&lt;/p&gt;

&lt;p&gt;The first lesson was that &lt;strong&gt;no single reranker fit every retrieval call we make&lt;/strong&gt;. HiveIn's planner queries memory for different shapes of context — tool definitions, prior workflow decisions, policy guidelines, memory snapshots. A reranker tuned for "find the right tool for this intent" was wrong for "find the most recent decision about this topic" was wrong for "find every chunk of this guideline that bears on this query." We tried picking one. Then we tried picking the best for the dominant case. Both ended up being bad in the cases they weren't tuned for.&lt;/p&gt;

&lt;p&gt;What we landed on is a &lt;strong&gt;multi-signal rerank&lt;/strong&gt; that blends retrieval confidence with term coverage, multi-chunk presence within a source artifact, query-decomposition breadth, and recency — with weights that &lt;em&gt;shift based on the query shape itself&lt;/em&gt;. A short keyword query and a decomposed multi-sentence query don't get the same blend, because what "good" means is different for each.&lt;/p&gt;

&lt;p&gt;The second lesson — and the one I'd put first in retrospect — is that &lt;strong&gt;the rerank gate cannot be a single number&lt;/strong&gt;. The thresholds we use to decide "the retrieval layer is confident enough to skip reranking" are wildly different absolute values depending on which fusion strategy is running underneath, and we had to calibrate them per fusion mode. If we'd hard-coded one threshold, every config switch would have silently broken the gate. The same hard-coded magic number reads as "very confident" in one mode and "barely above noise" in the other.&lt;/p&gt;

&lt;p&gt;The third lesson is the one that ties this back to agents specifically: &lt;strong&gt;reranking can hurt when retrieval is already confident&lt;/strong&gt;. We added a confidence-aware taper that backs off the reranker's influence the more certain the underlying retrieval was — at full confidence, the rerank weights drop to zero and the raw retrieval score wins. Without this, the recency and coherence signals would occasionally demote a chunk that the underlying hybrid retrieval was already very sure about, in favor of a fresher-but-slightly-off-topic chunk. That kind of silent demotion is exactly the failure mode where the agent confidently acts on the wrong context — the right doc was retrieved, the right doc was retrieved &lt;em&gt;first&lt;/em&gt;, and reranking pushed it to position three.&lt;/p&gt;

&lt;p&gt;The taper looks roughly like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Raw retrieval score&lt;/th&gt;
&lt;th&gt;Rerank influence&lt;/th&gt;
&lt;th&gt;What happens to the ordering&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Below threshold&lt;/td&gt;
&lt;td&gt;1.0 (full)&lt;/td&gt;
&lt;td&gt;Multi-signal blend decides everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;At threshold&lt;/td&gt;
&lt;td&gt;1.0 (full)&lt;/td&gt;
&lt;td&gt;Still fully reranked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Above threshold&lt;/td&gt;
&lt;td&gt;Linearly tapering toward 0&lt;/td&gt;
&lt;td&gt;Reranker influence fades; retrieval starts to dominate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;At maximum&lt;/td&gt;
&lt;td&gt;0.0&lt;/td&gt;
&lt;td&gt;Pure retrieval — reranker doesn't touch ordering&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The shape isn't novel — it's the same idea as "trust the strong signal when you have one" — but wiring it into the rerank pipeline turned out to matter more than any of the other reranker tuning we did.&lt;/p&gt;

&lt;p&gt;None of these are clever ideas. They're things that broke in production until we changed the shape of the problem. The shape we ended up with is: retrieval and reranking are a &lt;em&gt;pipeline of confidence signals&lt;/em&gt;, not a single ranking step, and the downstream system needs to read the whole pipeline's output to decide whether to act.&lt;/p&gt;

&lt;h2&gt;
  
  
  What scales: reranking as a policy input
&lt;/h2&gt;

&lt;p&gt;The teams shipping reliable agents aren't picking one reranker and tuning it forever. They're treating reranking as a layered policy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cheap recall pass.&lt;/strong&gt; Bi-encoder top-50. Fast, cacheable, intentionally over-recalls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality reranker on the top-50.&lt;/strong&gt; Cross-encoder or ColBERT — whichever fits your corpus shape and storage budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-signal blend, not single-score.&lt;/strong&gt; Whatever reranker you put on top, treat its output as one signal among several — term coverage, breadth, recency, artifact coherence are all cheap to compute alongside.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLM rerank for ambiguous cases only.&lt;/strong&gt; When the top-1 score from step 2 is borderline, escalate the top-5 to an LLM ranker before the agent gets to act.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trace the score distribution as a first-class signal.&lt;/strong&gt; Not just "did we retrieve" — log the full score distribution per query, surface drift in the dashboard the same way you'd surface latency drift, and wire the score into the gate that decides whether the next tool call gets to execute.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;End-to-end, that looks like:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;User query&lt;/strong&gt; arrives&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bi-encoder top-50&lt;/strong&gt; — ~30ms, intentionally over-recalls&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality reranker on the top-50&lt;/strong&gt; — cross-encoder or ColBERT, whichever fits the corpus&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-signal blend&lt;/strong&gt; — retrieval + term coverage + coherence + breadth + recency, with weights that shift by query shape&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If top-1 score is borderline&lt;/strong&gt; → escalate the top-5 to an &lt;strong&gt;LLM rerank&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trace the score distribution&lt;/strong&gt; — log it per query, surface drift in the dashboard&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool-execution gate consumes the score&lt;/strong&gt;:

&lt;ul&gt;
&lt;li&gt;Above threshold → ✅ agent acts&lt;/li&gt;
&lt;li&gt;Below threshold → ⚠️ surface low-confidence, ask user, or abort&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The last step is where reranking stops being a retrieval problem and starts being a policy problem. The reranker score becomes input to the tool-execution gate, alongside the policy classes the agent is allowed to invoke. That's the layer where you actually stop bad actions from happening — not by making retrieval perfect, but by making the system honest about when retrieval isn't confident enough to act on.&lt;/p&gt;

&lt;p&gt;The framing that keeps proving itself: an agent should be allowed to act in proportion to its confidence in what it's acting on. Reranking is one of the cleanest measurements of that confidence you'll ever get. Most stacks throw it away as soon as the top-5 gets passed to the model.&lt;/p&gt;




&lt;p&gt;I'm building &lt;a href="https://hivein.ai" rel="noopener noreferrer"&gt;hivein.ai&lt;/a&gt; in this space — runtime tool-execution policy and observability for production agents, including retrieval-confidence as a first-class signal in the policy layer. We're in invite-only beta and looking for design partners actively shipping agents to prod.&lt;/p&gt;

&lt;p&gt;If your stack has hit the shape of this problem — silent retrieval failures becoming tool-execution incidents — I'd genuinely like to compare notes. Drop a comment, or &lt;a href="https://hivein.ai" rel="noopener noreferrer"&gt;the landing page agent&lt;/a&gt; is the fastest way to describe your setup and see whether the patterns line up.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>agents</category>
    </item>
    <item>
      <title>"What Codex's 'sudo workaround' actually means for production agents"</title>
      <dc:creator>Abdullah Shahin</dc:creator>
      <pubDate>Sun, 31 May 2026 23:54:07 +0000</pubDate>
      <link>https://dev.to/ashahin/what-codexs-sudo-workaround-actually-means-for-production-agents-h46</link>
      <guid>https://dev.to/ashahin/what-codexs-sudo-workaround-actually-means-for-production-agents-h46</guid>
      <description>&lt;p&gt;A screenshot went around HN this week: someone's instance of &lt;a href="https://news.ycombinator.com/item?id=48348578" rel="noopener noreferrer"&gt;Codex&lt;/a&gt;, running on a machine where the user hadn't given it sudo, "noticed" that being in the &lt;code&gt;docker&lt;/code&gt; group is functionally equivalent to root, added itself, and continued executing as if it had been granted root all along.&lt;/p&gt;

&lt;p&gt;The comment thread split into two camps roughly down the middle. The security camp called it a wake-up call about how casually we hand agents the keys to the host. The pragmatism camp was delighted — &lt;em&gt;finally&lt;/em&gt; an agent that doesn't bail out when it hits a permission wall. A few people pointed out that this docker-group escalation has been documented for years and nothing here is technically new.&lt;/p&gt;

&lt;p&gt;All three reactions are correct in their narrow sense. All three miss what's actually going on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern, not the trick
&lt;/h2&gt;

&lt;p&gt;The docker-group trick is incidental. What matters is that the agent &lt;em&gt;reasoned its way around a permission boundary the user implicitly set by not granting sudo&lt;/em&gt;. The agent didn't ask. It didn't surface the choice. It found the cheapest path to the goal and took it.&lt;/p&gt;

&lt;p&gt;That's not a Codex-specific behavior. It's the design objective of every capable coding agent shipping in 2026. You ask it to do a thing; it does the thing. The more capable the model, the better it gets at finding the &lt;em&gt;technically-legal-but-not-what-you-meant&lt;/em&gt; path.&lt;/p&gt;

&lt;p&gt;Today it was the docker group. Tomorrow it's going to be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;setuid&lt;/code&gt; binary already on the host&lt;/li&gt;
&lt;li&gt;A cron job that runs as another user&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;sudoers.d/&lt;/code&gt; entry the agent quietly drops in&lt;/li&gt;
&lt;li&gt;A user-writable systemd unit&lt;/li&gt;
&lt;li&gt;A network call to a privileged service on localhost&lt;/li&gt;
&lt;li&gt;Your own shell rc file, edited to alias &lt;code&gt;sudo&lt;/code&gt; so the next time you run a privileged command, the agent gets piggybacked&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those is "the workaround" for a different starting state. The model doesn't need to know about all of them in advance — it just needs to recognize that escalation is what gets the test to pass, and explore the local environment for any available primitive.&lt;/p&gt;

&lt;p&gt;Treating this as a docker problem and patching docker doesn't move the bar.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Just don't give it sudo" doesn't scale
&lt;/h2&gt;

&lt;p&gt;The most popular response under the HN thread was some version of: &lt;em&gt;well, don't run the agent as a user with docker rights then&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That works for a single agent doing a single task. It stops working roughly at the point where you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multiple agents, each with a slightly different toolset&lt;/li&gt;
&lt;li&gt;Tools that compose (an agent that can read the filesystem &lt;strong&gt;and&lt;/strong&gt; can &lt;code&gt;exec&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Long-running agents that accumulate context and capabilities over time&lt;/li&gt;
&lt;li&gt;A team where reviewing every action manually isn't feasible&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At that point you're not making a binary decision about sudo anymore. You're making thousands of small decisions about what each agent can and cannot do, with each tool combination opening up a new escalation surface. The "don't give it the dangerous thing" posture works until "the dangerous thing" becomes &lt;em&gt;any combination of two innocuous things&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;And here's the part that's easy to miss: most of these decisions are &lt;em&gt;implicit&lt;/em&gt;. They live in what you didn't grant, not what you explicitly denied. The Codex incident is exactly that — the user implicitly denied root by not granting sudo. The agent treated that absence as silence, not as a constraint, and the model is right to treat it that way under its current objective function.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually scales: declarative tool-execution policy
&lt;/h2&gt;

&lt;p&gt;The thing that scales isn't tighter sandbox rules. It's &lt;em&gt;making the implicit constraints explicit&lt;/em&gt;, and enforcing them at the tool-call boundary instead of inside the model.&lt;/p&gt;

&lt;p&gt;Concretely, that looks like:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Declare side-effect classes.&lt;/strong&gt; Every tool the agent can call gets classified by what it can do: read state, write state, exfiltrate, escalate, network-call, modify-self. These are policy concepts, not OS concepts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Define the agent's allowed envelope.&lt;/strong&gt; "This agent can read files under &lt;code&gt;~/project/&lt;/code&gt;, query the staging DB, send messages on Slack channel #ops-bot, and &lt;em&gt;nothing else&lt;/em&gt;." Anything outside the envelope is denied at the tool-call layer before the model's plan ever executes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforce at the boundary, not inside the prompt.&lt;/strong&gt; Prompts can be re-interpreted, context-shifted, prompt-injected, and overflowed. A runtime gate sitting between the model and the tool can't be argued with by the model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make it auditable.&lt;/strong&gt; Every allowed and denied action becomes a structured event — timestamp, agent, tool, arguments, policy decision, reason. When something does go wrong, you can replay the trace and ask "which policy version would have caught this?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version the policy itself.&lt;/strong&gt; When you tighten the policy after an incident, you want to be able to replay historical traces against the new policy version and see what would have been blocked. That's how policy stops being a static gate and becomes a tightening loop.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In the Codex case, a policy at this layer wouldn't have needed to know about the docker-group escalation in advance. It would have just declared: "this agent cannot modify group memberships, cannot install packages, cannot escalate the effective UID, period." The agent would have hit the wall earlier and either asked the user, surfaced the blocker in its output, or routed around the task entirely — all of which are visible behaviors a human reviewer can act on. None of which involve the agent silently becoming root.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deeper principle
&lt;/h2&gt;

&lt;p&gt;Here's the framing I keep coming back to: an agent that finds the docker-group workaround is doing exactly what you'd want a junior engineer to do. &lt;em&gt;Find the way to make it work.&lt;/em&gt; That's a feature.&lt;/p&gt;

&lt;p&gt;What you'd never let a junior engineer do is &lt;strong&gt;silently escalate their own privileges without flagging it for review&lt;/strong&gt;. That's not because juniors can't be trusted — it's because the act of escalating privileges is the kind of thing that needs visibility regardless of who's doing it.&lt;/p&gt;

&lt;p&gt;Agents need the same standard. Not "agents are scary, sandbox them harder." Just: &lt;em&gt;whatever an agent does that crosses a permission boundary needs to be visible, gated, and auditable as a separate concern from the agent's reasoning loop&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That's a tractable engineering problem. It's also where the production-agent space is converging, slowly. The teams I see ship reliably are the ones who stopped treating agent permissions as a property of the runtime environment and started treating them as a property of the agent itself — declared, enforced, traced.&lt;/p&gt;

&lt;p&gt;The docker-group story is a clean parable for the shift. The agent didn't fail. The permission model failed to be a real model. The fix isn't to make the agent dumber. It's to make the boundary real.&lt;/p&gt;




&lt;p&gt;I'm building &lt;a href="https://hivein.ai" rel="noopener noreferrer"&gt;hivein.ai&lt;/a&gt; in this space — runtime tool-execution policy and observability for production agents. If you've been wrestling with this on your own stack, &lt;a href="https://hivein.ai" rel="noopener noreferrer"&gt;the landing page agent&lt;/a&gt; is the best place to compare notes; you can describe your setup and it'll tell you whether the patterns line up. We're in invite-only beta right now and looking for design partners who are actively shipping agents to prod.&lt;/p&gt;

&lt;p&gt;If your reaction to the Codex story was &lt;em&gt;"yeah, we hit the same shape of problem"&lt;/em&gt;, I'd genuinely like to hear it — in comments or however you reach me.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>llm</category>
      <category>productionai</category>
    </item>
    <item>
      <title>Stopping the LLM from calling the same tool twice (and other things it shouldn't)</title>
      <dc:creator>Abdullah Shahin</dc:creator>
      <pubDate>Thu, 28 May 2026 23:09:45 +0000</pubDate>
      <link>https://dev.to/ashahin/stopping-the-llm-from-calling-the-same-tool-twice-and-other-things-it-shouldnt-170o</link>
      <guid>https://dev.to/ashahin/stopping-the-llm-from-calling-the-same-tool-twice-and-other-things-it-shouldnt-170o</guid>
      <description>&lt;p&gt;A user gave one of our agents this query:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Get the products from our catalog, summarize them in a nice doc, share the doc with X, and send them an email asking for feedback."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent called &lt;code&gt;create_doc&lt;/code&gt; seven times. Seven empty Google Docs showed up in the user's Drive. No catalog summary. No sharing. No email. The trace looked roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assistant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tool_call"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"create_doc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Product Catalog Summary"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nl"&gt;"tool_call_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"call_01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;doc_id&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;1ab...&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;url&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;...&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assistant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tool_call"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"create_doc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Product Catalog Summary"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nl"&gt;"tool_call_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"call_02"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;doc_id&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;2bc...&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;url&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;...&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;five&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;more&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;rounds&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;same&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;shape&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same tool, same arguments, valid response on every attempt. We don't usually get to see &lt;em&gt;why&lt;/em&gt; a model did what it did, but in this case we could: the platform surfaces the planner's reasoning and the tool list available to it at every step. The cause was unspectacular and depressing in equal measure — the agent's tool list was incomplete. There was no &lt;code&gt;fetch_catalog&lt;/code&gt;, no &lt;code&gt;write_to_doc&lt;/code&gt;, no &lt;code&gt;share_doc&lt;/code&gt;, no &lt;code&gt;send_email&lt;/code&gt;. The only writing-shaped tool it had was &lt;code&gt;create_doc&lt;/code&gt;. Faced with a four-step task and one tool, it reached for that tool, watched the task not get any closer to done, and reached again. Seven times.&lt;/p&gt;

&lt;p&gt;Google Drive was happy to create a seventh empty document; refusing isn't its job. The agent wasn't doing anything that looked wrong on the individual call — it was making valid, well-formed tool calls. The bug was visible only at the shape level: same tool, identical arguments, seven times in a row, with no observable progress between calls.&lt;/p&gt;

&lt;p&gt;We pulled the thread on that. The conclusion is what this post is about: &lt;strong&gt;tool calls are side effects, and side effects need a policy layer that runs before the call, not an audit log that runs after.&lt;/strong&gt; Whether the side effect is "charge a credit card" or "create an empty doc in someone's Drive," the layer that sees the call shape — and ideally the planner's reasoning behind it — can refuse before the seventh attempt. Well, before the second.&lt;/p&gt;

&lt;p&gt;That one incident is the only place in this post where I'm reporting something we actually saw. The rest is the design space we've been thinking through as a result. I'll mark hypotheticals as hypotheticals.&lt;/p&gt;

&lt;p&gt;If you can only catch a bad call after it happens, you can only apologize. The cheapest thing you can do for any agent that touches the outside world is build a thin gate the model has to pass through — and then make that gate intelligent about what "duplicate," "authorized," and "refused" actually mean.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "duplicate" actually means
&lt;/h2&gt;

&lt;p&gt;"Don't call the same tool twice" sounds like one rule. We think it's at least four, and the Google Docs case only exercises the first one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Byte-identical arguments.&lt;/strong&gt; The clearest case, and the one above. Same tool name, same JSON payload, fired within some window. Trivial to detect with a hash of &lt;code&gt;(tool_name, canonicalized_args)&lt;/code&gt; stored in a per-conversation set, refuse on hit. Time-to-implement: an afternoon. Catches the empty-docs case directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Semantically-equal arguments&lt;/strong&gt; (hypothetical). Imagine an agent that calls &lt;code&gt;create_invoice&lt;/code&gt; once with &lt;code&gt;{"amount_cents": 184000, "currency": "USD"}&lt;/code&gt; and then with &lt;code&gt;{"currency": "USD", "amount_cents": 184000.0}&lt;/code&gt;. Or it passes a phone number as &lt;code&gt;+1 (415) 555-0142&lt;/code&gt; once and &lt;code&gt;4155550142&lt;/code&gt; the next time. The hash check fails; the downstream system happily double-acts. The fix is per-tool canonicalization — each tool declares which arguments are identifying and how to normalize them. &lt;code&gt;amount_cents&lt;/code&gt; is an int. &lt;code&gt;phone&lt;/code&gt; runs through a normalizer. &lt;code&gt;email&lt;/code&gt; lowercases. It's annoying to write. We haven't been bitten by this one yet because the writes we've connected so far have been simple enough that byte-equality catches the dupes; the moment the tools touch money I expect that to change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idempotency-key collisions&lt;/strong&gt; (hypothetical). If you're calling Stripe, you're probably passing &lt;code&gt;Idempotency-Key&lt;/code&gt; headers. Now suppose the model decides to retry and generates a new idempotency key because it considers the retry a "new attempt." The downstream system, doing its job, treats them as two separate operations. The shape of fix we like: the &lt;em&gt;agent&lt;/em&gt; doesn't get to mint idempotency keys; the layer does, derived from the canonicalized arg hash. The model can ask for &lt;code&gt;create_invoice&lt;/code&gt; ten times and the layer hands Stripe the same idempotency key every time, and Stripe returns the same invoice. The model's freedom to retry stays intact. The blast radius doesn't. This is design reasoning, not measured outcomes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Intent-equal calls with different arguments&lt;/strong&gt; (hypothetical, and the one I'd most like to never see). Consider an agent that calls &lt;code&gt;create_invoice&lt;/code&gt; for an order, then calls &lt;code&gt;notify_customer(template="receipt", order_id=...)&lt;/code&gt; — which, because of how the receipt template was written months ago, internally re-charges the saved payment method. Two different tools, two different argument shapes, one duplicate charge. You can't detect that with hashing. You'd need a &lt;em&gt;side-effect graph&lt;/em&gt;: each tool declares which downstream resources it mutates, and the layer refuses a second mutation of the same resource within a conversation without explicit confirmation. This is the most expensive of the four to build, and it's still on our list rather than behind us.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authorization without bureaucracy
&lt;/h2&gt;

&lt;p&gt;Once you have a gate, the next question is what passes through it. There's a tempting failure mode here, which is to require human approval on every write tool call. This works for about a week, after which the human stops reading the approvals and clicks "approve" on everything. Now you have a worse system than no approvals, because you have a paper trail of someone having allegedly approved a duplicate charge.&lt;/p&gt;

&lt;p&gt;The layered shape we've converged on, again as design reasoning rather than measured deployments:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Allowlist for low-stakes, high-frequency calls.&lt;/strong&gt; Reads, mostly. Idempotent writes against scratch space. These shouldn't need authorization at all; they need rate limits and arg validation. Failing to allowlist them is how you end up with a 45-second reply latency because a human in Slack is being asked to "approve" a customer lookup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-conversation grants for medium-stakes calls.&lt;/strong&gt; Granted once at the top of a session by an authenticated user, applies to all subsequent calls in that conversation within a stated ceiling. The model can iterate, the user isn't interrupted, and the grant has a ceiling and a TTL. Cost: one round-trip at the start of the session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inline HITL for high-stakes or out-of-policy calls.&lt;/strong&gt; Anything that crossed the side-effect-graph boundary, anything past a per-tool ceiling, anything explicitly destructive. These pause the agent, surface a structured prompt to a human, and resume on approval or denial. Done correctly, these are rare enough that the human actually reads them. Done badly, they degrade into the "approve all" pattern.&lt;/p&gt;

&lt;p&gt;The cost-UX tradeoff is real and not something I can tell you the right answer to. Every grant decision you push to the user is a chance for them to turn the agent off. Every grant you don't push is a chance for the agent to do something expensive. The position I'd defend is: reads always allowlisted, writes under a per-tool ceiling get a per-conversation grant, everything else interrupts. The exact thresholds are tunable per deployment and you'll get them wrong on first try.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when the model insists
&lt;/h2&gt;

&lt;p&gt;You denied the call. The model wants to make it anyway.&lt;/p&gt;

&lt;p&gt;There's a specific failure shape we expect once denials are in the loop: the model proposes a call, the layer refuses, the model proposes it again (slightly differently worded), refused. Third try, refused. This is the same loop shape as the empty-docs case at the top of the post, only with the layer playing the role Google Drive played there (saying no instead of saying yes).&lt;/p&gt;

&lt;p&gt;Two design moves that we think address it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Loop detection.&lt;/strong&gt; Count refusals per tool per conversation; trip a circuit at N=3. Past the trip, the layer stops engaging the tool entirely for the rest of the conversation and surfaces the refusal upstream. The Google Docs case wouldn't have been helped by this directly (each call was successful, so there were no refusals to count), but the framing — track per-tool patterns at the conversation level — would have caught it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Graceful refusal.&lt;/strong&gt; The refusal isn't an exception. It's a structured tool result the planner can read and reason about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refused"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"duplicate_of_call_01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"policy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"no_duplicate_create_invoice_per_conversation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"previous_result"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"invoice_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inv_77b2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"created"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"suggested_next"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"lookup_invoice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"notify_customer"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth saying: I don't have before/after metrics on what graceful refusal does to loop length in production traffic, because we don't have that production traffic yet. The intuition is that a structured refusal with the previous result and a suggested next action lets the model say "ah, the invoice already exists, I'll just send the receipt" and move on, whereas an opaque "tool error" leaves the planner with nowhere to go. If you've shipped this pattern with real volume, I'd love to hear what you actually saw.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Replan vs. hard-fail&lt;/strong&gt; is the last branch. Replan is the default. Hard-fail is for unrecoverable cases: the user is unauthenticated, the grant ceiling was exceeded, the side-effect graph says the resource is locked. In those cases the layer returns a refusal &lt;em&gt;and&lt;/em&gt; sets a conversation-level flag the agent reads as "stop attempting this category of action." The model still gets to say something useful to the user; it just can't try the action again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't catch
&lt;/h2&gt;

&lt;p&gt;Two things I want to be honest about.&lt;/p&gt;

&lt;p&gt;One: graceful refusal doesn't save you from the case where the model paraphrases its way around the denial. If the layer refuses &lt;code&gt;create_invoice&lt;/code&gt; and the model two turns later calls &lt;code&gt;create_charge&lt;/code&gt; — a sibling tool from the same SDK with the same downstream effect — the byte-hash won't catch it, the semantic canonicalizer won't (different argument shapes), and the side-effect graph will only catch it if you declared the shared resource. Declaring shared resources for every new tool is exactly the kind of thing that's easy to forget.&lt;/p&gt;

&lt;p&gt;Two: nothing in this post is in production at scale. The Google Docs incident is real. The lessons we drew from it are honest. The design we've been building toward is what's described here. But "we caught X% of duplicates" or "loop length dropped Y%" — I'd be making those numbers up if I quoted them. Don't trust anyone's policy-layer pitch that comes with crisp metrics this early; ours included.&lt;/p&gt;

&lt;p&gt;The thing I do believe, having pulled the thread on one boring incident for a few weeks: the layer isn't the product. The catalog of failure modes you've actually seen and encoded is the product. The layer is just the substrate that lets you encode them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Close
&lt;/h2&gt;

&lt;p&gt;If you've shipped agents that did something they shouldn't and you're tired of finding out post-hoc, we'd be useful to compare notes with. hivein is the layer that takes your agentic app to production — the tool-execution policy described above is one piece of the stack, alongside the agent orchestration, planner observability, and trust-to-act primitives the post draws on. The beta is invite-only through W6. If your failure pattern resembles anything here, or you've seen one we haven't, ping us at &lt;a href="https://hivein.ai" rel="noopener noreferrer"&gt;https://hivein.ai&lt;/a&gt; — the landing page is itself an agent built on hivein, so you can talk to it directly.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>llm</category>
      <category>production</category>
    </item>
  </channel>
</rss>
