<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged agentical</title>
<link>https://coderlegion.com/tag/agentical</link>
<description>Powered by Question2Answer</description>
<item>
<title>Your Agent Died at Step 7 of 12: Durable Execution for Long-Running Agents</title>
<link>https://coderlegion.com/27907/your-agent-died-at-step-7-of-12-durable-execution-for-long-running-agents</link>
<description>&lt;p&gt;The agent was twenty minutes into a migration task. It had read the schema, planned eleven steps, executed six of them — two of which wrote to production — and was waiting on the model for step seven when the pod was rescheduled.&lt;/p&gt;
&lt;p&gt;Everything the agent knew lived in a Python list called &lt;code&gt;messages&lt;/code&gt;, in the memory of a process that no longer exists.&lt;/p&gt;
&lt;p&gt;You now have three bad options. Redo the whole run, and steps 1–6 happen a second time, including the two that wrote to production. Abandon it, and leave a migration half-applied. Or have a human reconstruct what happened from logs, which is what most teams actually do, at 11 p.m.&lt;/p&gt;
&lt;p&gt;None of these are retry problems. Retries handle a 429 or a 529 inside a process that is still alive — &lt;a rel=&quot;nofollow&quot; href=&quot;https://cursuri-ai.ro/en/courses/advanced-llm-integration-in-production&quot;&gt;a separate and well-understood layer&lt;/a&gt;. This is what happens when the process itself goes away, and the fix is architectural: &lt;strong&gt;the run has to live in the database, not in the heap.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;why-long-agent-runs-die-and-why-it-is-not-exotic&quot;&gt;Why long agent runs die, and why it is not exotic&lt;/h3&gt;
&lt;p&gt;A ten-second chat completion effectively never gets interrupted. A twenty-minute agent run gets interrupted constantly, because twenty minutes is long enough to collide with ordinary operations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A deploy.&lt;/strong&gt; You ship twice a day. Any run longer than your drain timeout is killed by your own CI.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A platform timeout.&lt;/strong&gt; Lambda caps at 15 minutes. Cloud Run request timeouts, ALB idle timeouts, and Cloudflare's limits all sit below &quot;agent doing real work&quot;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OOM.&lt;/strong&gt; Agent runs accumulate tool results — a large file read, a big query result — and the process footprint grows with the transcript.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A long backoff.&lt;/strong&gt; You hit a rate limit with a &lt;code&gt;retry-after&lt;/code&gt; of 90 seconds, and something upstream decides the worker is hung.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A crash in your own tool code.&lt;/strong&gt; An unhandled exception in step 7 takes the loop down with it, and the loop was holding the only copy of steps 1–6.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The frequency is what surprises people. If a run takes 20 minutes and you deploy twice a day with a 30-second drain, a meaningful fraction of in-flight runs die every single day. At one run an hour that is invisible; at a thousand it is a daily incident.&lt;/p&gt;
&lt;h3 id=&quot;the-model-a-run-is-a-state-machine-not-a-for-loop&quot;&gt;The model: a run is a state machine, not a for-loop&lt;/h3&gt;
&lt;p&gt;The mental shift is small and everything follows from it. Today your loop probably looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def run_agent(task: str) -&amp;gt; str:
    messages = [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: task}]
    while True:
        resp = client.messages.create(model=MODEL, messages=messages, tools=TOOLS, max_tokens=8000)
        messages.append({&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: resp.content})
        if resp.stop_reason != &quot;tool_use&quot;:
            return text_of(resp)
        results = [execute(b) for b in resp.content if b.type == &quot;tool_use&quot;]
        messages.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: results})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;messages&lt;/code&gt; is the state, the heap is the storage, and the process is the transaction boundary. Kill the process and the state is gone.&lt;/p&gt;
&lt;p&gt;The durable version separates three things that the loop above conflates:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The run&lt;/strong&gt; — a row with a status and a task. It outlives every worker.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The steps&lt;/strong&gt; — an append-only log of what happened, in order. This &lt;em&gt;is&lt;/em&gt; the conversation; &lt;code&gt;messages&lt;/code&gt; becomes a projection of it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The worker&lt;/strong&gt; — a disposable process that leases a run, advances it one step, commits, and repeats. Any worker can pick up any run at any time.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Step 7 crashing now means: the run is still &lt;code&gt;running&lt;/code&gt;, steps 1–6 are committed, and the next worker to pick up the lease rebuilds the message list from the log and continues at step 7. Nothing is redone.&lt;/p&gt;
&lt;h3 id=&quot;the-schema&quot;&gt;The schema&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE agent_run (
    id            UUID PRIMARY KEY,
    task          TEXT NOT NULL,
    status        TEXT NOT NULL,          -- pending|running|succeeded|failed|cancelled
    model         TEXT NOT NULL,          -- pin it: see the append-only section
    lease_owner   TEXT,                   -- worker id holding this run
    lease_until   TIMESTAMPTZ,            -- heartbeat expiry
    attempt       INT  NOT NULL DEFAULT 0,
    result        TEXT,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE agent_step (
    run_id        UUID NOT NULL REFERENCES agent_run(id),
    seq           INT  NOT NULL,          -- 0,1,2... strictly increasing per run
    kind          TEXT NOT NULL,          -- model_turn | tool_result
    payload       JSONB NOT NULL,         -- the raw content blocks, verbatim
    request_id    TEXT,                   -- provider request id, for support tickets
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (run_id, seq)
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two constraints carry most of the weight.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;PRIMARY KEY (run_id, seq)&lt;/code&gt;&lt;/strong&gt; makes double-writes impossible. If two workers somehow both try to append step 7, one of them gets a unique violation and loses. That is not an edge case to handle later; it is the safety property that lets you run more than one worker.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Steps are append-only.&lt;/strong&gt; Never &lt;code&gt;UPDATE agent_step&lt;/code&gt;. A retried tool call is a new step with a new &lt;code&gt;seq&lt;/code&gt; and its own error payload, not an overwrite of the failed one. You lose nothing, you can replay any run exactly, and post-mortems stop being fiction.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;payload&lt;/code&gt; is verbatim.&lt;/strong&gt; Store the provider's content blocks as they arrived — do not normalize, do not strip fields you think are noise. The next section is about why that is not merely good hygiene.&lt;/p&gt;
&lt;h3 id=&quot;rebuilding-the-conversation-append-only-history-is-now-enforced&quot;&gt;Rebuilding the conversation: append-only history is now enforced&lt;/h3&gt;
&lt;p&gt;Resume means reconstructing &lt;code&gt;messages&lt;/code&gt; from the step log:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def rebuild_messages(db, run_id: str) -&amp;gt; list[dict]:
    steps = db.query(
        &quot;SELECT kind, payload FROM agent_step WHERE run_id = %s ORDER BY seq&quot;, run_id
    )
    messages = [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: steps[0][&quot;payload&quot;][&quot;task&quot;]}]
    for step in steps[1:]:
        role = &quot;assistant&quot; if step[&quot;kind&quot;] == &quot;model_turn&quot; else &quot;user&quot;
        messages.append({&quot;role&quot;: role, &quot;content&quot;: step[&quot;payload&quot;]})
    return messages
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Simple — and there is one trap in it that turns a working resume into a hard 400 on the current generation of models.&lt;/p&gt;
&lt;p&gt;Anthropic's Fable 5.1 made conversation history &lt;strong&gt;append-only&lt;/strong&gt;. Each &lt;code&gt;thinking&lt;/code&gt; block is cryptographically bound to the system prompt, the tool definitions, and the history that preceded it. Modify any of that on the client and you get &lt;code&gt;Invalid &quot;signature&quot; in &quot;thinking&quot; block&lt;/code&gt; — a 400, not a degraded response. (Enforcement is implicit for accounts created from 31 August 2026 onward; older accounts can opt in with the &lt;code&gt;thinking-binding-controls-2026-08-01&lt;/code&gt; beta to find out now rather than later.)&lt;/p&gt;
&lt;p&gt;Resume is precisely the operation that tempts you to modify history. Three rules keep you on the right side of it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Store payloads verbatim.&lt;/strong&gt; A &quot;cleanup&quot; that drops a field you considered redundant will break the signature. Serialize what the SDK gave you, whole.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pin the model on the run row.&lt;/strong&gt; &lt;code&gt;thinking&lt;/code&gt; blocks are bound to the model that produced them; 5.1 can read blocks from older models, but not the reverse. A worker that picks up an old run and reads &lt;code&gt;MODEL&lt;/code&gt; from environment config — which you just bumped — resumes with the wrong model and fails at a distance from the cause.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pin the system prompt and tool definitions too, or version them.&lt;/strong&gt; Deploying a reworded system prompt mid-run invalidates the binding for every in-flight run. Store a &lt;code&gt;prompt_version&lt;/code&gt; on the run and resolve the text from a versioned registry.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That last rule catches teams weeks later: resume worked perfectly in testing, then the day someone edits the system prompt, every run that survives the deploy fails on its next turn. Note that server-side compaction and context editing do &lt;em&gt;not&lt;/em&gt; count as client-side modification — this constraint is about what &lt;strong&gt;you&lt;/strong&gt; rewrite.&lt;/p&gt;
&lt;h3 id=&quot;idempotency-the-step-that-spends-money&quot;&gt;Idempotency: the step that spends money&lt;/h3&gt;
&lt;p&gt;Persisting the transcript fixes the &quot;redo the whole run&quot; problem. It does not fix the narrower and more expensive one: a tool call executed, and the process died before the result was committed.&lt;/p&gt;
&lt;p&gt;You cannot tell those two apart from the outside. All you know at resume time is that step 7 has no result. The tool may have done nothing, or it may have charged a card.&lt;/p&gt;
&lt;p&gt;At-least-once delivery is the only guarantee a crash-safe system can offer. Exactly-once execution is achieved the same way it is everywhere else in distributed systems: &lt;strong&gt;at-least-once delivery plus idempotent effects.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import hashlib, json

def idempotency_key(run_id: str, seq: int, tool: str, args: dict) -&amp;gt; str:
    canonical = json.dumps(args, sort_keys=True, separators=(&quot;,&quot;, &quot;:&quot;))
    return hashlib.sha256(f&quot;{run_id}:{seq}:{tool}:{canonical}&quot;.encode()).hexdigest()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The key is derived from &lt;em&gt;position in the run&lt;/em&gt;, not from time or a UUID. Two attempts at step 7 produce the same key; step 8 produces a different one. Pass it to anything with a side effect — Stripe, your own API, a queue — and the second attempt returns the first result instead of performing the action again.&lt;/p&gt;
&lt;p&gt;For tools you own and that have no natural idempotency support, record the effect in the same transaction as the step:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def execute_step(db, run, seq, tool_use):
    key = idempotency_key(run[&quot;id&quot;], seq, tool_use.name, tool_use.input)

    cached = db.fetchone(&quot;SELECT result FROM tool_effect WHERE key = %s&quot;, key)
    if cached:
        return cached[&quot;result&quot;]                  # already happened; do not repeat

    result = TOOLS[tool_use.name](**tool_use.input)

    with db.transaction():
        db.insert(&quot;tool_effect&quot;, key=key, result=result)
        db.insert(&quot;agent_step&quot;, run_id=run[&quot;id&quot;], seq=seq,
                  kind=&quot;tool_result&quot;, payload=serialize(result))
    return result
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is still a window — between &lt;code&gt;TOOLS[...]()&lt;/code&gt; returning and the transaction committing — where a crash loses the record of an effect that happened. It cannot be closed without a distributed transaction, and you do not want one. Shrink it instead: for genuinely dangerous tools, write an &lt;code&gt;intent&lt;/code&gt; row &lt;em&gt;before&lt;/em&gt; calling, so resume finds &quot;we were about to charge this card&quot; and can ask a human rather than guess. High-risk actions are also exactly where a &lt;a rel=&quot;nofollow&quot; href=&quot;https://cursuri-ai.ro/en/courses/ai-agents-architecture-and-automation&quot;&gt;human approval gate&lt;/a&gt; belongs, and an approval record is itself a durable checkpoint — the two designs reinforce each other.&lt;/p&gt;
&lt;p&gt;Tool classification, done once, at design time:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Class &lt;/th&gt;&lt;th&gt;Example &lt;/th&gt;&lt;th&gt;Resume policy&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Pure read &lt;/td&gt;&lt;td&gt;&lt;code&gt;get_schema&lt;/code&gt;, &lt;code&gt;search_docs&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Just re-run it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Idempotent write &lt;/td&gt;&lt;td&gt;&lt;code&gt;set_status(id, &quot;done&quot;)&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Re-run; same end state&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Non-idempotent &lt;/td&gt;&lt;td&gt;&lt;code&gt;charge_card&lt;/code&gt;, &lt;code&gt;send_email&lt;/code&gt;, &lt;code&gt;post_message&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Idempotency key, mandatory&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Irreversible &lt;/td&gt;&lt;td&gt;&lt;code&gt;DROP TABLE&lt;/code&gt;, &lt;code&gt;terminate_instance&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Intent row + human confirmation&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The row that gets skipped is the third one, and it is the one that shows up in a customer complaint.&lt;/p&gt;
&lt;h3 id=&quot;leases-stopping-two-workers-from-running-the-same-run&quot;&gt;Leases: stopping two workers from running the same run&lt;/h3&gt;
&lt;p&gt;Once runs are in a table and workers are disposable, the obvious failure is two workers picking up the same run — one that appeared dead but was only in a long backoff. Both advance it, both append steps, and the primary key saves you from corruption but not from duplicate tool calls.&lt;/p&gt;
&lt;p&gt;A lease with a heartbeat fixes it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def claim_run(db, worker_id: str, lease_seconds: int = 60):
    return db.fetchone(&quot;&quot;&quot;
        UPDATE agent_run
           SET lease_owner = %s,
               lease_until = now() + make_interval(secs =&amp;gt; %s),
               status      = 'running',
               attempt     = attempt + 1
         WHERE id = (
             SELECT id FROM agent_run
              WHERE status IN ('pending', 'running')
                AND (lease_until IS NULL OR lease_until &amp;lt; now())
              ORDER BY created_at
              FOR UPDATE SKIP LOCKED
              LIMIT 1
         )
     RETURNING *;
    &quot;&quot;&quot;, worker_id, lease_seconds)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; is what makes this safe under concurrency without a separate queue: two workers polling simultaneously get two different runs, never the same one. The worker renews &lt;code&gt;lease_until&lt;/code&gt; on every committed step. If it dies, the lease expires and the run becomes claimable — with no supervisor process needed to notice the death.&lt;/p&gt;
&lt;p&gt;Keep the lease short (60 seconds) and renew often, rather than long and hopeful. A long lease means a dead worker's run sits idle for the length of the lease. Renew on each step and, if a single step can exceed the lease, from a background heartbeat thread.&lt;/p&gt;
&lt;p&gt;Guard against poison runs with &lt;code&gt;attempt&lt;/code&gt;: a run that has been claimed eight times is failing deterministically and must go to a dead-letter status. Without that check, one malformed run will be retried by your fleet forever.&lt;/p&gt;
&lt;h3 id=&quot;cancellation-for-free&quot;&gt;Cancellation, for free&lt;/h3&gt;
&lt;p&gt;A benefit that pays for the whole design on its own: with the run in a table, cancellation is an &lt;code&gt;UPDATE&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# in the worker, checked before each step
if db.value(&quot;SELECT status FROM agent_run WHERE id = %s&quot;, run_id) == &quot;cancelled&quot;:
    return
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Try cancelling an in-flight &lt;code&gt;while True&lt;/code&gt; loop inside an anonymous process. You cannot, which is why so many agent products have no stop button — and why so many have a stop button that only hides the UI while the tokens keep burning.&lt;/p&gt;
&lt;p&gt;The same table makes the operational surface trivial: runs by status, oldest pending run, p95 steps per run, attempts distribution. That is a dashboard and an alert, built from data you already have. Pipe &lt;code&gt;run_id&lt;/code&gt; into every log line and a support question (&quot;what did it do for this customer?&quot;) becomes one query instead of a log-grep expedition. This is the &lt;a rel=&quot;nofollow&quot; href=&quot;https://cursuri-ai.ro/en/courses/ai-for-devops-and-sre-aiops&quot;&gt;ordinary SRE treatment applied to agents&lt;/a&gt;, and it works because you made the agent ordinary software.&lt;/p&gt;
&lt;h3 id=&quot;when-to-use-a-workflow-engine-instead&quot;&gt;When to use a workflow engine instead&lt;/h3&gt;
&lt;p&gt;Everything above is roughly 200 lines on top of Postgres. Temporal, Restate, and similar durable-execution engines give you the same guarantees with the replay semantics handled for you.&lt;/p&gt;
&lt;p&gt;Build it yourself when: you already run Postgres, your steps are minutes not days, and you want the step log in the same database as your application data so you can join against it. The debugging story — &lt;code&gt;SELECT * FROM agent_step WHERE run_id = ...&lt;/code&gt; — is genuinely better than an opaque engine's event history.&lt;/p&gt;
&lt;p&gt;Use an engine when: runs span days and involve human waits, you need timers and signals, you are coordinating multiple agents that must rendezvous, or you already run one for other workloads. Do not adopt one &lt;em&gt;only&lt;/em&gt; for agents; the operational cost is real, and an agent run is a simpler shape than the workflows these engines were built for.&lt;/p&gt;
&lt;p&gt;The part that is not optional either way is idempotency. Temporal will happily re-execute your non-idempotent activity during a replay. Durable execution guarantees that the &lt;em&gt;workflow&lt;/em&gt; resumes correctly, never that your side effects are safe.&lt;/p&gt;
&lt;h3 id=&quot;faq&quot;&gt;FAQ&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Does this apply to a 10-second chat completion?&lt;/strong&gt;&lt;br&gt;
No. If a run cannot outlive a deploy, an in-memory loop is the right amount of machinery. The threshold is roughly &quot;longer than your drain timeout&quot; — past that, interruption stops being an edge case and becomes a daily event.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can I just store the &lt;code&gt;messages&lt;/code&gt; list as a JSON blob after every turn?&lt;/strong&gt;&lt;br&gt;
It survives a crash, which is most of the benefit, and it is a reasonable first version. What it does not give you is a per-step primary key (so concurrent workers can corrupt it), a record of failed attempts, or a queryable history. The step table is maybe forty more lines and you stop rewriting the same row under concurrency.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Do I need a queue like SQS or Celery in front of this?&lt;/strong&gt;&lt;br&gt;
Not for correctness — &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; is a perfectly good work queue at moderate volume, and keeping the queue in the same database means claiming a run and committing a step share one transaction. Add a broker when you need fan-out to many worker types or cross-service routing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What happens to a run whose model was retired mid-flight?&lt;/strong&gt;&lt;br&gt;
It fails, and it should. Pinning the model on the run row is what makes that a clean, early, obvious failure instead of a confusing one. Drain long runs before removing a model from your rotation, and treat any run still pinned to a dead model as a dead-letter case for a human.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Is streaming compatible with this?&lt;/strong&gt;&lt;br&gt;
Yes. Stream to the user for responsiveness, and commit the assembled message to the step log before dispatching any tool. The checkpoint boundary is the completed turn, not the token.&lt;/p&gt;
&lt;h3 id=&quot;checklist&quot;&gt;Checklist&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;[ ] Every model turn and tool result is committed to an append-only step log before the next call&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;PRIMARY KEY (run_id, seq)&lt;/code&gt; — duplicate steps are impossible, not merely unlikely&lt;/li&gt;
&lt;li&gt;[ ] Payloads stored verbatim; model, system prompt, and tool definitions pinned per run&lt;/li&gt;
&lt;li&gt;[ ] Every tool classified: pure / idempotent / non-idempotent / irreversible&lt;/li&gt;
&lt;li&gt;[ ] Non-idempotent tools take a key derived from &lt;code&gt;run_id + seq + args&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;[ ] Irreversible tools write an intent row before executing&lt;/li&gt;
&lt;li&gt;[ ] Leases with &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; and a heartbeat; &lt;code&gt;attempt&lt;/code&gt; capped with a dead-letter status&lt;/li&gt;
&lt;li&gt;[ ] Cancellation is a status change the worker checks between steps&lt;/li&gt;
&lt;li&gt;[ ] A &lt;code&gt;run_id&lt;/code&gt; in every log line&lt;/li&gt;
&lt;li&gt;[ ] A test that kills the worker mid-run and asserts the resumed run does not repeat a side effect&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That last one is the only way to know any of this works. Run the agent, &lt;code&gt;SIGKILL&lt;/code&gt; the worker between steps 3 and 4, start a new worker, and assert the effect count. Do it in CI. Crash-safety that has never been crashed is a hypothesis.&lt;/p&gt;
&lt;h3 id=&quot;the-short-version&quot;&gt;The short version&lt;/h3&gt;
&lt;p&gt;Agent runs are long enough to be interrupted by routine operations, and an in-memory &lt;code&gt;messages&lt;/code&gt; list is not a place to keep work you cannot afford to redo. Move the run into a table, make the step log append-only, rebuild the conversation on resume, and make every side-effecting tool idempotent by position. None of this is AI-specific — it is the same durability discipline as any long-running job. The only genuinely new constraint is that modern models bind their reasoning blocks to the exact history that produced them, so &quot;rebuild the conversation&quot; now means &lt;em&gt;byte-identical&lt;/em&gt;, not &lt;em&gt;equivalent&lt;/em&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;I build and teach production AI systems at &lt;a rel=&quot;nofollow&quot; href=&quot;https://cursuri-ai.ro/en/courses&quot;&gt;Cursuri-AI.ro&lt;/a&gt;, Eastern Europe's AI education platform. If you are taking an agent from a working demo to something that survives a deploy, the &lt;a rel=&quot;nofollow&quot; href=&quot;https://cursuri-ai.ro/en/courses/build-and-ship-a-production-ai-saas&quot;&gt;production AI SaaS track&lt;/a&gt; covers this end to end.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27907/your-agent-died-at-step-7-of-12-durable-execution-for-long-running-agents</guid>
<pubDate>Mon, 21 Sep 2026 09:25:59 +0000</pubDate>
</item>
<item>
<title>Why Your RAG Pipeline Returns Wrong Answers (7 Failure Modes and Fixes)</title>
<link>https://coderlegion.com/20911/why-your-rag-pipeline-returns-wrong-answers-7-failure-modes-and-fixes</link>
<description>&lt;p&gt;You built a RAG system. You embedded your docs, wired up a vector store, stuffed the top chunks into the prompt, and shipped. Then the questions came back wrong — confidently, fluently wrong — and your first instinct was to blame the model or reach for a bigger one.&lt;/p&gt;
&lt;p&gt;Here's the uncomfortable truth after debugging a lot of these: &lt;strong&gt;the model is almost never the problem.&lt;/strong&gt; In a Retrieval-Augmented Generation pipeline, the model can only answer from what you put in its context. If retrieval hands it the wrong chunks, even a frontier model will produce a beautifully written wrong answer. RAG failures are retrieval failures wearing a generation costume.&lt;/p&gt;
&lt;p&gt;This is a field guide to the seven failure modes that cause most bad RAG answers, how to recognize each one, and the concrete fix. No new framework to adopt — just the debugging order that actually finds the bug.&lt;/p&gt;
&lt;h3 id=&quot;first-the-one-diagnostic-that-saves-you-hours&quot;&gt;First, the one diagnostic that saves you hours&lt;/h3&gt;
&lt;p&gt;Before touching anything, answer one question: &lt;strong&gt;was the correct information in the retrieved chunks at all?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Log the actual chunks your retriever returned for a failing query and read them with your own eyes. This splits every RAG bug into two worlds:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The answer was &lt;em&gt;not&lt;/em&gt; in the retrieved chunks&lt;/strong&gt; → you have a &lt;strong&gt;retrieval&lt;/strong&gt; problem (failure modes 1–5 below). The generator never stood a chance.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The answer &lt;em&gt;was&lt;/em&gt; in the chunks but the model ignored or mangled it&lt;/strong&gt; → you have a &lt;strong&gt;generation&lt;/strong&gt; problem (failure modes 6–7).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Skipping this step is why people waste days tuning prompts when the real bug is that the right paragraph was never retrieved. Do this first, every time.&lt;/p&gt;
&lt;p&gt;One more split worth making early: is the question &lt;strong&gt;single-hop&lt;/strong&gt; (&quot;what is our refund window?&quot;) or &lt;strong&gt;multi-hop&lt;/strong&gt; (&quot;how does our refund window compare to the EU default?&quot;). Multi-hop questions need facts from &lt;em&gt;several&lt;/em&gt; documents at once, and a top-k retriever tuned for single-hop will reliably fetch one of the two needed pieces and miss the other. If your failures cluster on comparative or multi-part questions, the fix isn't a better embedding — it's query decomposition (break the question into sub-queries, retrieve for each, then synthesize), which is a different lever than everything below.&lt;/p&gt;
&lt;h3 id=&quot;failure-mode-1-your-chunking-destroys-meaning&quot;&gt;Failure mode 1: Your chunking destroys meaning&lt;/h3&gt;
&lt;p&gt;The single most common cause of bad retrieval is naive chunking — splitting documents every &lt;em&gt;N&lt;/em&gt; characters with no regard for structure. You end up with chunks that cut a sentence in half, separate a heading from its content, or split a table from the row that gives it meaning. An embedding of half a thought retrieves poorly because it &lt;em&gt;is&lt;/em&gt; half a thought.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Symptoms:&lt;/strong&gt; retrieved chunks are topically close but never contain the complete fact; answers are vague or stitched from fragments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; chunk along the document's natural structure, not a fixed byte count.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Split on semantic boundaries — paragraphs, sections, Markdown headings — not blind character windows.&lt;/li&gt;
&lt;li&gt;Add &lt;strong&gt;overlap&lt;/strong&gt; (e.g. 10–20%) so a fact straddling a boundary survives in at least one chunk.&lt;/li&gt;
&lt;li&gt;Keep chunks coherent: one chunk should answer one thing. For structured docs, keep a table or list with its caption.&lt;/li&gt;
&lt;li&gt;Attach metadata to each chunk (source, section title, date) — you'll use it for filtering and citations later.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There's no universal chunk size; it depends on your documents and your embedding model's sweet spot. Measure it (see failure mode 7), don't guess. Chunking is deceptively deep — it's where a structured &lt;a rel=&quot;nofollow&quot; href=&quot;https://cursuri-ai.ro/courses/rag-retrieval-augmented-generation&quot;&gt;course on RAG and retrieval-augmented generation&lt;/a&gt; spends real time, because getting it right fixes more bugs than any prompt tweak.&lt;/p&gt;
&lt;h3 id=&quot;failure-mode-2-the-wrong-embedding-model-or-a-mismatch-&quot;&gt;Failure mode 2: The wrong embedding model (or a mismatch)&lt;/h3&gt;
&lt;p&gt;Your embedding model decides what &quot;similar&quot; means. Two failures hide here.&lt;/p&gt;
&lt;p&gt;First, a &lt;strong&gt;weak or generic embedding model&lt;/strong&gt; can't tell apart concepts that matter in your domain — legal clauses, medical terms, internal product names all collapse into the same neighborhood. Second, and more insidious: a &lt;strong&gt;mismatch between query and document embeddings.&lt;/strong&gt; If you embed documents with one model and queries with another, or you embed a terse 6-word question and expect it to land near a dense 400-token passage, the vectors live in different worlds.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Symptoms:&lt;/strong&gt; retrieval returns plausibly-related-but-not-relevant chunks; performance is mediocre across the board rather than failing on specific queries.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Use a strong, current embedding model suited to your domain and language — for non-English content, verify it was actually trained for it.&lt;/li&gt;
&lt;li&gt;Embed queries and documents with the &lt;strong&gt;same&lt;/strong&gt; model and the same preprocessing.&lt;/li&gt;
&lt;li&gt;Consider &lt;strong&gt;query transformation&lt;/strong&gt;: rewrite the short user question into a richer form (or generate a hypothetical answer and embed &lt;em&gt;that&lt;/em&gt; — the HyDE technique) so it sits closer to real passages.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;failure-mode-3-pure-vector-search-misses-exact-terms&quot;&gt;Failure mode 3: Pure vector search misses exact terms&lt;/h3&gt;
&lt;p&gt;Dense vector search is great at semantics and surprisingly bad at exact matches — error codes, SKUs, function names, dates, proper nouns. A user asking about &lt;code&gt;ERR_CONN_4021&lt;/code&gt; doesn't want &quot;semantically similar&quot; connection errors; they want &lt;em&gt;that&lt;/em&gt; string. Embeddings smear it into a neighborhood and the exact doc ranks fifth.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Symptoms:&lt;/strong&gt; great on conceptual questions, weak on specific identifiers, names, or numbers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; &lt;strong&gt;hybrid search.&lt;/strong&gt; Combine dense vector search with classic keyword search (BM25/full-text) and fuse the results (Reciprocal Rank Fusion is a simple, strong default). You get semantics &lt;em&gt;and&lt;/em&gt; exact-match precision. Most production-grade vector stores support hybrid retrieval now — turn it on.&lt;/p&gt;
&lt;h3 id=&quot;failure-mode-4-no-re-ranking-so-the-best-chunk-isn-t-on-top&quot;&gt;Failure mode 4: No re-ranking, so the best chunk isn't on top&lt;/h3&gt;
&lt;p&gt;First-stage retrieval optimizes for recall — cast a wide net and return, say, the top 50 candidates. But you only feed a handful to the model, and the &lt;em&gt;ordering&lt;/em&gt; of that first stage is coarse. The genuinely best chunk is often in your top 50 but not your top 5, so it never reaches the prompt.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Symptoms:&lt;/strong&gt; when you log the top 20–50 candidates, the right chunk is in there — just ranked too low to make the cut.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; add a &lt;strong&gt;re-ranking&lt;/strong&gt; stage. Retrieve a generous candidate set, then run a cross-encoder reranker that scores each candidate &lt;em&gt;against the query directly&lt;/em&gt; (far more accurate than the initial vector distance) and keep the top few after re-ranking. This two-stage &quot;retrieve wide, re-rank narrow&quot; pattern is one of the highest-ROI upgrades in RAG and often fixes the &quot;it was almost right&quot; class of bugs outright. Designing this multi-stage retrieval well is core to a deeper &lt;a rel=&quot;nofollow&quot; href=&quot;https://cursuri-ai.ro/courses/advanced-llm-integration&quot;&gt;course on advanced LLM integration&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;failure-mode-5-stale-duplicated-or-contradictory-sources&quot;&gt;Failure mode 5: Stale, duplicated, or contradictory sources&lt;/h3&gt;
&lt;p&gt;Retrieval is only as good as the corpus. If your index contains last quarter's pricing alongside this quarter's, three near-duplicate copies of the same policy, or contradictory drafts, retrieval will faithfully surface the wrong-but-similar one. The pipeline isn't broken — your data is.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Symptoms:&lt;/strong&gt; answers are outdated, contradict each other across runs, or cite a deprecated document.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Deduplicate&lt;/strong&gt; before indexing, and keep the index fresh — re-index or invalidate on source changes, don't let it drift.&lt;/li&gt;
&lt;li&gt;Store a &lt;code&gt;date&lt;/code&gt;/&lt;code&gt;version&lt;/code&gt; in chunk metadata and &lt;strong&gt;filter or boost by recency&lt;/strong&gt; at query time.&lt;/li&gt;
&lt;li&gt;Have a single source of truth per fact; archive superseded docs out of the active index.&lt;/li&gt;
&lt;li&gt;Garbage in, garbage retrieved. Data hygiene is unglamorous and it outperforms most clever tricks.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;failure-mode-6-the-context-is-right-but-the-prompt-undermines-it&quot;&gt;Failure mode 6: The context is right but the prompt undermines it&lt;/h3&gt;
&lt;p&gt;Now the generation side. Suppose the correct chunk &lt;em&gt;is&lt;/em&gt; in context but the answer is still wrong. Often the prompt is the culprit: it doesn't tell the model to ground its answer in the provided context, doesn't give it permission to say &quot;I don't know,&quot; or buries the relevant chunk among ten irrelevant ones so the signal drowns (the &quot;lost in the middle&quot; effect, where models attend least to the middle of a long context).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Symptoms:&lt;/strong&gt; the right info is in the prompt, yet the model invents, ignores it, or pads with prior knowledge.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Instruct explicitly: &lt;em&gt;&quot;Answer &lt;strong&gt;only&lt;/strong&gt; using the context below. If the answer isn't there, say you don't know.&quot;&lt;/em&gt; This single line kills a surprising amount of hallucination.&lt;/li&gt;
&lt;li&gt;Pass &lt;strong&gt;fewer, better&lt;/strong&gt; chunks — re-ranking (mode 4) makes this possible. More context is not more better; irrelevant chunks actively hurt.&lt;/li&gt;
&lt;li&gt;Put the most relevant chunks at the &lt;strong&gt;start or end&lt;/strong&gt;, not buried in the middle.&lt;/li&gt;
&lt;li&gt;Ask for &lt;strong&gt;citations&lt;/strong&gt; — make the model name which chunk each claim came from. It both reduces fabrication and gives you a debugging trail.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Deciding &lt;em&gt;what&lt;/em&gt; deserves a place in the context window — and what to leave out — is its own discipline, increasingly called context engineering, and it's the focus of a dedicated &lt;a rel=&quot;nofollow&quot; href=&quot;https://cursuri-ai.ro/courses/context-engineering-memorie-agenti&quot;&gt;course on context engineering for AI agents&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;failure-mode-7-you-have-no-evals-so-you-re-debugging-blind&quot;&gt;Failure mode 7: You have no evals, so you're debugging blind&lt;/h3&gt;
&lt;p&gt;This is the meta-failure that makes all the others permanent. If &quot;is it working?&quot; is answered by eyeballing a few queries, you can't tell whether a change helped, hurt, or just moved the failures around. Every fix above becomes a coin flip.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Symptoms:&lt;/strong&gt; you &quot;improve&quot; the pipeline and have no idea if it actually got better; regressions ship silently.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; build an &lt;strong&gt;evaluation set&lt;/strong&gt; and measure, separately, the two halves of the pipeline.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Retrieval metrics&lt;/strong&gt; — on a set of questions with known relevant documents, measure recall@k and precision@k. This tells you whether the right chunk is even being retrieved (the diagnostic from the intro, made systematic).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Generation metrics&lt;/strong&gt; — given the retrieved context, measure faithfulness (does the answer stay grounded in the context?) and answer relevance. LLM-as-judge scoring works well here when calibrated.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Even 30–50 representative question/answer pairs turn RAG tuning from vibes into engineering: you change one thing, you re-run, you see the number move. Treating evals as first-class is exactly the muscle a &lt;a rel=&quot;nofollow&quot; href=&quot;https://cursuri-ai.ro/courses/ai-evals-llm-productie&quot;&gt;course on AI evals in production&lt;/a&gt; builds, and it's the difference between &quot;I think the reranker helped&quot; and &quot;recall@5 went from 0.61 to 0.84.&quot;&lt;/p&gt;
&lt;h3 id=&quot;the-debugging-order-in-one-list&quot;&gt;The debugging order, in one list&lt;/h3&gt;
&lt;p&gt;When a RAG answer is wrong, resist the urge to swap models. Work the pipeline in this order:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Log the retrieved chunks.&lt;/strong&gt; Was the answer even in there? This alone routes you to retrieval vs. generation.&lt;/li&gt;
&lt;li&gt;If not retrieved → check &lt;strong&gt;chunking&lt;/strong&gt; (1), &lt;strong&gt;embeddings&lt;/strong&gt; (2), &lt;strong&gt;hybrid search&lt;/strong&gt; (3), &lt;strong&gt;re-ranking&lt;/strong&gt; (4), and &lt;strong&gt;data freshness&lt;/strong&gt; (5), roughly in that order of likelihood.&lt;/li&gt;
&lt;li&gt;If retrieved but ignored → fix the &lt;strong&gt;prompt and context layout&lt;/strong&gt; (6).&lt;/li&gt;
&lt;li&gt;Throughout, &lt;strong&gt;measure with evals&lt;/strong&gt; (7) so every change is verified, not hoped.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Nine times out of ten the bug is in steps 1–2, not the model. Working the steps in order also stops you from stacking fixes blindly — change one variable, re-measure, and you'll usually find a single failure mode is responsible for most of your bad answers.&lt;/p&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;RAG looks simple in the tutorial — embed, search, prompt — and that simplicity is exactly why broken RAG is so common. The hard parts are invisible until you debug them: how you split documents, how you embed and search, how you re-rank, how clean your corpus is, and whether you can measure any of it. None of those are model problems, and none are solved by paying for a bigger model.&lt;/p&gt;
&lt;p&gt;The teams whose RAG actually works aren't using a secret model. They're disciplined about retrieval and they measure everything. Start with the diagnostic — log your chunks — fix the failure mode it points to, and put an eval set behind every change. That's how a RAG demo becomes a RAG system people can trust.&lt;/p&gt;
&lt;p&gt;The courses linked throughout are part of &lt;a rel=&quot;nofollow&quot; href=&quot;https://cursuri-ai.ro/courses/rag-retrieval-augmented-generation&quot;&gt;Cursuri-AI.ro&lt;/a&gt;, an AI-learning platform with hands-on, current tracks on RAG, LLM integration, context engineering, and evaluating AI systems in production.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Sources &amp;amp; further reading:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Lewis et al. — &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2005.11401&quot;&gt;Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks (original RAG paper)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Liu et al. — &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2307.03172&quot;&gt;Lost in the Middle: How Language Models Use Long Contexts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Gao et al. — &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2212.10496&quot;&gt;Precise Zero-Shot Dense Retrieval without Relevance Labels (HyDE)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;This article is educational content. Techniques and tooling evolve quickly; validate approaches against your own data and current library documentation.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/20911/why-your-rag-pipeline-returns-wrong-answers-7-failure-modes-and-fixes</guid>
<pubDate>Thu, 18 Jun 2026 13:21:58 +0000</pubDate>
</item>
</channel>
</rss>