---
title: "The Agent That Doesn't Improvise"
description: "How I orchestrated a safety-critical Malaysian legal research agent as a fixed LangGraph state machine, with deterministic guardrails that decide when to answer and when to refuse."
date: "2026-06-06"
tags: ["ai", "agents", "langgraph", "orchestration"]
---

# The Agent That Doesn't Improvise

The default move when you build with LLMs is to hand the model as much autonomy as you can. You define some tools, write a system prompt, and let it decide which tools to call and in what order. For a lot of tasks that works surprisingly well, and it's genuinely the right starting point.

For the [legal research assistant](https://github.com/aishahsofea/ai-legal-tool) I've been building, I wasn't convinced it was the right default. The question wasn't whether the model was capable, but whether it would be consistent. An autonomous agent might decide a citation check isn't needed for a particular query, or reason that a disclaimer is already implied. Sometimes it'd be right; sometimes not. And in a legal context, the times it's wrong are the whole problem: a confident, wrong-sounding answer is worse than no answer at all.

So I went the other way and built a fixed pipeline. Every step is a node in a graph and every transition is a condition written in plain code. LangGraph's `StateGraph` is what makes this tractable: you write nodes as functions that read from and write to a shared state object, wire them together with edges, and let the graph handle execution.

Here's the trade-off I was making, laid out plainly.

<div class="not-prose my-8 grid gap-3 sm:grid-cols-2">
  <div class="rounded-xl border border-red-200 bg-red-50/50 p-5 dark:border-red-900/40 dark:bg-red-950/20">
    <h4 class="mb-3 flex items-center gap-2 text-sm font-semibold text-red-700 dark:text-red-300">Autonomous agent <span class="rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-red-600 dark:bg-red-900/40 dark:text-red-300">Rejected</span></h4>
    <ul class="space-y-2 text-[13px] leading-relaxed text-zinc-600 dark:text-zinc-400">
      <li>The model decides which steps to take at runtime</li>
      <li>No guaranteed order, so safety checks can quietly get skipped</li>
      <li>Non-deterministic, which makes it hard to audit or predict cost</li>
      <li>Can reason its way past a safety rule when it decides the rule doesn't apply</li>
    </ul>
  </div>
  <div class="rounded-xl border border-emerald-200 bg-emerald-50/50 p-5 dark:border-emerald-900/40 dark:bg-emerald-950/20">
    <h4 class="mb-3 flex items-center gap-2 text-sm font-semibold text-emerald-700 dark:text-emerald-300">Explicit workflow <span class="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-600 dark:bg-emerald-900/40 dark:text-emerald-300">Chosen</span></h4>
    <ul class="space-y-2 text-[13px] leading-relaxed text-zinc-600 dark:text-zinc-400">
      <li>Every step runs in a fixed, auditable sequence</li>
      <li>Safety checks are structurally guaranteed</li>
      <li>Explicit stopping points written in code</li>
      <li>Predictable cost: at most two drafting attempts per query</li>
    </ul>
  </div>
</div>

## What actually happens when a question comes in

The shape of it is simple. Classify the question, search the law, write an answer, check that answer, then either send it or loop back. It's in the same order every single time.

Before I get into the steps, it's worth understanding how they talk to each other. Well, they don't. No step calls the next one or passes it a return value. Instead every step reads from and writes to one shared object I'll call the **agent state**. Think of it as a notepad the whole pipeline passes around.

So when the first step finishes classifying a question, it doesn't hand anything to the next step. It records what it worked out onto the notepad, and the graph reads the notepad to decide where to go. A later step drafts an answer and writes that down too; the citation check picks that draft back up from the same place. Each step only cares about what's already on the notepad when it runs, and what it's supposed to add before it hands control back.

> **Before the pipeline even starts**, the very first step scans the incoming message for phrasing that sounds like someone asking about their own situation — "my client", "am I liable", "I have been charged". If it spots one, the question is flagged as out of scope and handed straight off, without ever touching the database or calling a model. The questions with the most at stake end up costing almost nothing to handle.

The diagram below is the whole thing wired up. Blue nodes make a generative LLM call. Green nodes are deterministic: no model decides what comes out. *(The one asterisk is the retriever: it embeds your query to run the vector search, but nothing it returns is model-authored, so it behaves deterministically from the pipeline's point of view.)* Amber nodes are control logic that steers the flow. Hover or tap any node to see exactly which state fields it reads and which it writes.

> **Interactive Agent Graph Diagram:** [view it in the web article](/blog/the-agent-that-doesnt-improvise)

Look at the `synthesiser`, for example. It reads `retrieved_chunks` and `response_language`, both written by earlier nodes, and it writes `draft_response` and `citations`, which the validators pick up next. It knows nothing about any other node. It only knows state. That decoupling is what makes the pipeline easy to reason about, easy to test, and easy to re-order without anything silently breaking.

## How I check whether the answer is trustworthy

Once the model has written an answer, how do I actually know it's right? I landed on three checks: `citation_validator`, `grounding_check`, and `supervisor`.

The first is a plain Python function with no model involved. It looks at every citation in the draft and asks whether that act-and-section pair was actually in the retrieved results. If the answer cites Section 90A of the Evidence Act, was Section 90A really fetched? It either was or it wasn't.

The second is where I bring in a second LLM as a judge. It reads each legal claim in the draft next to the statute text that was cited and decides whether the text genuinely supports what the answer says. This is the check that catches the subtle stuff: the model citing a real section but misreading it, or overstating what it actually says. I use Claude Sonnet at temperature 0, and each claim comes back labelled **supported**, **partial**, or **unsupported**. Only **unsupported** blocks the answer. I allow **partial** through, because a slightly incomplete claim is still useful for a research tool.

The third check is rules again, four of them enforced with regular expressions:

1. No advice phrases.
2. At least one statute citation in the expected format.
3. A disclaimer that this isn't legal advice.
4. And none of the personal-situation phrases from the router's escalation list appearing in the response itself — a guard against the model accidentally echoing the user's phrasing back ("you asked whether you are liable…") instead of staying in research mode.

One shortcut worth pointing out: if the first check finds a problem, I skip the second one. No reason to pay an LLM judge to grade a draft that's already going back for a rewrite.

<div class="not-prose my-8 overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-800">
  <div class="flex items-stretch border-b border-zinc-200 dark:border-zinc-800">
    <div class="flex w-16 shrink-0 items-center justify-center bg-emerald-50 text-lg font-black text-emerald-600 dark:bg-emerald-950/30 dark:text-emerald-500">L1</div>
    <div class="flex-1 px-5 py-4">
      <div class="mb-1 flex items-center gap-2"><code class="font-mono text-[13px] font-semibold text-zinc-800 dark:text-zinc-200">citation_validator</code><span class="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-600 dark:bg-emerald-900/40 dark:text-emerald-400">Deterministic</span></div>
      <div class="text-[13px] leading-relaxed text-zinc-500 dark:text-zinc-400">Are the cited act/section pairs present in what was actually retrieved? Do the prose citations match the structured list? Pure Python, no AI involved.</div>
    </div>
    <div class="flex w-20 shrink-0 flex-col items-end justify-center gap-1 px-3"><span class="text-sm font-bold text-emerald-600">$0</span><span class="text-[10px] text-zinc-400 dark:text-zinc-600">fast</span></div>
  </div>
  <div class="flex items-center gap-2 border-b border-zinc-200 bg-amber-50/40 py-2.5 pl-[88px] pr-5 text-[12px] text-amber-700 dark:border-zinc-800 dark:bg-amber-950/10 dark:text-amber-500">
    ↓ If L1 finds issues, skip L2 and go straight to the supervisor. No point paying a judge to grade a draft that's already being rewritten.
  </div>
  <div class="flex items-stretch border-b border-zinc-200 dark:border-zinc-800">
    <div class="flex w-16 shrink-0 items-center justify-center bg-blue-50 text-lg font-black text-blue-600 dark:bg-blue-950/30 dark:text-blue-500">L2</div>
    <div class="flex-1 px-5 py-4">
      <div class="mb-1 flex items-center gap-2"><code class="font-mono text-[13px] font-semibold text-zinc-800 dark:text-zinc-200">grounding_check</code><span class="rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-blue-600 dark:bg-blue-900/40 dark:text-blue-400">LLM judge</span></div>
      <div class="text-[13px] leading-relaxed text-zinc-500 dark:text-zinc-400">Does the cited statute text actually support each legal claim? Claude Sonnet at temperature 0 labels each one <em>supported</em>, <em>partial</em>, or <em>unsupported</em>. Only <em>unsupported</em> blocks the answer.</div>
    </div>
    <div class="flex w-20 shrink-0 flex-col items-end justify-center gap-1 px-3"><span class="text-sm font-bold text-amber-600">~$0.01</span><span class="text-[10px] text-zinc-400 dark:text-zinc-600">~1–2s</span></div>
  </div>
  <div class="flex items-stretch">
    <div class="flex w-16 shrink-0 items-center justify-center bg-emerald-50 text-lg font-black text-emerald-600 dark:bg-emerald-950/30 dark:text-emerald-500">L3</div>
    <div class="flex-1 px-5 py-4">
      <div class="mb-1 flex items-center gap-2"><code class="font-mono text-[13px] font-semibold text-zinc-800 dark:text-zinc-200">supervisor</code><span class="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-600 dark:bg-emerald-900/40 dark:text-emerald-400">Deterministic</span></div>
      <div class="text-[13px] leading-relaxed text-zinc-500 dark:text-zinc-400">Four hard rules checked with regex: no advice phrases, at least one statute citation, a disclaimer present, and no escalation language leaked into the response.</div>
    </div>
    <div class="flex w-20 shrink-0 flex-col items-end justify-center gap-1 px-3"><span class="text-sm font-bold text-emerald-600">$0</span><span class="text-[10px] text-zinc-400 dark:text-zinc-600">fast</span></div>
  </div>
</div>

Why the stack is shaped this way matters more than the individual checks. L1 and L3 are deterministic, so AI reasoning can't make them wrong. L2 is the only model-based check in the verification step, and it sits between two layers that don't depend on it. Model judgment is bounded on both sides by code that can't hallucinate a false pass.

> **A bug that took this down in production.** I wrote the supervisor's citation regex while only testing with Claude. Claude writes citations like "Section 90A of the Evidence Act 1950." GPT-4.1 writes "Section 90A(1) states that…", with the subsection in the middle and the act name earlier in the sentence. My regex didn't match that style, so Rule 2 fired on every non-Claude answer, forced a retry, and then returned a refusal. GPT-4.1 went from a 30% to an 80% pass rate just by widening that one pattern. A deterministic rule is only ever as good as the variation you wrote it to handle.

## Two exits, two different problems

Both of the system's stopping points end with the user being pointed toward a lawyer, but they're not the same thing and it's worth keeping them apart.

The first happens before any real work. A keyword check on the incoming message catches phrasing that sounds like a request for personal legal advice, and if it matches, the pipeline stops right there, before any database query or model call. The question is out of scope.

The second happens after the system has done everything it can. Both drafting attempts have failed the checks, so rather than send something I can't verify, the final answer gets replaced with a safe fallback. That overwrite happens outside the graph, not inside a node, so no future rewiring of the pipeline can accidentally skip it. The question was in scope. I just couldn't answer it well enough.

<div class="not-prose my-8 grid gap-3 sm:grid-cols-2">
  <div class="overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-800">
    <div class="flex items-center gap-3 border-b border-zinc-200 px-5 py-4 dark:border-zinc-800">
      <div class="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100 text-base dark:bg-amber-950/40">⚡</div>
      <div><h4 class="text-sm font-semibold text-zinc-800 dark:text-zinc-200">Escalation</h4><div class="text-[11px] text-zinc-400 dark:text-zinc-500">Stops at the beginning</div></div>
    </div>
    <dl class="px-5 py-3 text-[12px]">
      <div class="flex justify-between gap-3 border-b border-zinc-100 py-2 dark:border-zinc-800/60"><dt class="text-zinc-400 dark:text-zinc-500">Triggered by</dt><dd class="text-right text-zinc-700 dark:text-zinc-300">Pattern match on the message</dd></div>
      <div class="flex justify-between gap-3 border-b border-zinc-100 py-2 dark:border-zinc-800/60"><dt class="text-zinc-400 dark:text-zinc-500">When</dt><dd class="text-right text-zinc-700 dark:text-zinc-300">Before retrieval or any LLM call</dd></div>
      <div class="flex justify-between gap-3 border-b border-zinc-100 py-2 dark:border-zinc-800/60"><dt class="text-zinc-400 dark:text-zinc-500">Cost</dt><dd class="text-right font-medium text-emerald-600">Zero, the cheapest possible stop</dd></div>
      <div class="flex justify-between gap-3 py-2"><dt class="text-zinc-400 dark:text-zinc-500">Guards against</dt><dd class="text-right text-zinc-700 dark:text-zinc-300">Requests for personal advice</dd></div>
    </dl>
  </div>
  <div class="overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-800">
    <div class="flex items-center gap-3 border-b border-zinc-200 px-5 py-4 dark:border-zinc-800">
      <div class="flex h-9 w-9 items-center justify-center rounded-lg bg-red-100 text-base dark:bg-red-950/40">🛑</div>
      <div><h4 class="text-sm font-semibold text-zinc-800 dark:text-zinc-200">Fail-closed</h4><div class="text-[11px] text-zinc-400 dark:text-zinc-500">Stops at the end</div></div>
    </div>
    <dl class="px-5 py-3 text-[12px]">
      <div class="flex justify-between gap-3 border-b border-zinc-100 py-2 dark:border-zinc-800/60"><dt class="text-zinc-400 dark:text-zinc-500">Triggered by</dt><dd class="text-right text-zinc-700 dark:text-zinc-300">Unresolved violations after both tries</dd></div>
      <div class="flex justify-between gap-3 border-b border-zinc-100 py-2 dark:border-zinc-800/60"><dt class="text-zinc-400 dark:text-zinc-500">When</dt><dd class="text-right text-zinc-700 dark:text-zinc-300">After the full pipeline + one retry</dd></div>
      <div class="flex justify-between gap-3 border-b border-zinc-100 py-2 dark:border-zinc-800/60"><dt class="text-zinc-400 dark:text-zinc-500">Cost</dt><dd class="text-right font-medium text-red-500">Full, the most expensive stop</dd></div>
      <div class="flex justify-between gap-3 py-2"><dt class="text-zinc-400 dark:text-zinc-500">Guards against</dt><dd class="text-right text-zinc-700 dark:text-zinc-300">Shipping an unverifiable answer</dd></div>
    </dl>
  </div>
</div>

Using a keyword match for escalation feels a little crude, and it won't catch every way someone might phrase a request for advice. But when it gets something wrong, it gets it wrong on the safe side. It flags a question that didn't need flagging rather than letting a personal-advice request slip through. That asymmetry mattered to me more than raw coverage did.

## These are all named patterns

It's worth naming what this architecture actually is, because the patterns map cleanly onto established ones. All three (**routing**, **prompt chaining**, and **evaluator-optimizer**) come straight from [Anthropic's guide on building effective agents](https://www.anthropic.com/engineering/building-effective-agents). The router is routing. The fixed sequence of steps is prompt chaining. The synthesiser-to-checker loop is evaluator-optimizer.

<div class="not-prose my-8 overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-800">
  <div class="grid grid-cols-[110px_1fr] gap-3 border-b border-zinc-200 bg-zinc-50 px-5 py-2.5 text-[10px] font-bold uppercase tracking-wide text-zinc-400 dark:border-zinc-800 dark:bg-zinc-900/50 dark:text-zinc-500 sm:grid-cols-[130px_1fr_1fr]"><span>Pattern</span><span class="hidden sm:block">What it means</span><span>Where it shows up here</span></div>
  <div class="grid grid-cols-[110px_1fr] items-center gap-3 border-b border-zinc-100 px-5 py-3 dark:border-zinc-800/60 sm:grid-cols-[130px_1fr_1fr]">
    <span class="text-[13px] font-semibold text-accent">Routing</span>
    <span class="hidden text-[12px] leading-snug text-zinc-500 dark:text-zinc-400 sm:block">Classify input, send it down the right branch</span>
    <span class="font-mono text-[11px] leading-snug text-zinc-700 dark:text-zinc-300">router → retriever or escalate</span>
  </div>
  <div class="grid grid-cols-[110px_1fr] items-center gap-3 border-b border-zinc-100 px-5 py-3 dark:border-zinc-800/60 sm:grid-cols-[130px_1fr_1fr]">
    <span class="text-[13px] font-semibold text-accent">Prompt chaining</span>
    <span class="hidden text-[12px] leading-snug text-zinc-500 dark:text-zinc-400 sm:block">Each step feeds the next, no back-tracking</span>
    <span class="font-mono text-[11px] leading-snug text-zinc-700 dark:text-zinc-300">router → retriever → synthesiser</span>
  </div>
  <div class="grid grid-cols-[110px_1fr] items-center gap-3 px-5 py-3 sm:grid-cols-[130px_1fr_1fr]">
    <span class="text-[13px] font-semibold text-accent">Evaluator-optimizer</span>
    <span class="hidden text-[12px] leading-snug text-zinc-500 dark:text-zinc-400 sm:block">One model generates, another evaluates, loop</span>
    <span class="font-mono text-[11px] leading-snug text-zinc-700 dark:text-zinc-300">synthesiser ↔ grounding_check + retry</span>
  </div>
</div>

## Things I gave up on purpose

None of these decisions are obviously right. Each one trades something useful away, and I think it's more honest to name what.

1. **A fixed pipeline instead of an autonomous agent**

    The clearest thing I gave up is adaptability. A fixed pipeline can't reshape itself around a question I didn't anticipate, and there's no room for the model to improvise a different approach when one might genuinely help. I took that deal because the payoff is the opposite property: every answer travels the exact same safety path, every time, with no shortcut the model can reason its way into.

2. **At most one retry**

    Capping retries at one means some drafts that might have recovered on a second or third pass get refused instead. But in testing, convergence past the first retry was rare, since the model kept working from the same retrieved evidence and arriving at pretty much the same place. The extra attempts mostly added latency and cost without changing the outcome, and bounding the count keeps the worst-case cost of any query predictable.

3. **The retry re-enters at drafting, not retrieval**

    A failed draft goes back to the `synthesiser`, not all the way back to `retrieval`. The cost is that I can't recover from a bad retrieval this way. If the wrong statute sections were fetched, re-drafting won't fix it. I accepted that because the failures I actually saw were drafting errors, misreading or overstating the law, rather than evidence errors, so re-running retrieval would just re-fetch chunks that were already fine.

4. **Deterministic rules bookend the LLM judge**

    Surrounding the judge with hard-coded checks is more to write and maintain than just asking a model to review everything. Worth it, because rules can't hallucinate a false pass. The bookends have no failure mode of their own, so a bad call from the judge can't on its own push an answer out the door.

5. **Escalation by pattern match, not a model**

    A regex instead of a classifier is brittle, and new ways of phrasing a request for personal advice will slip through. But it costs nothing, adds no latency, and fails safe when it's wrong. I'd rather over-flag than let one slip past.

## What I'd do differently

This is a pilot, not a finished system, and writing it up made the gaps a lot easier to see. Four things I'd change first.

1. **Make the retry informed**

    Right now `increment_retry` wipes the violations and sends the synthesiser back at the same prompt and the same sections, without ever telling it what went wrong. The second attempt is a re-roll rather than a correction. This is probably the single biggest weakness, and almost certainly why retries past the first one stopped helping: there was no new information to converge on. I'd feed the specific violation reasons back into the next draft so the model knows exactly what to fix.

2. **Keep every policy string in one place**

    The disclaimer text and the escalation phrases live across the router, the synthesiser, and the supervisor, and they've already drifted. The supervisor only recognises the English disclaimer, so a perfectly good Bahasa Malaysia answer can fail closed, and the escalation patterns differ between the two nodes that define them. Any rule defined in more than one place is a calibration bug waiting to happen. I'd derive them all from one shared module so the drafter and the checker can't disagree.

3. **Verify every claim, not just the cited ones**

    The grounding judge only inspects claims attached to a citation that was both named in the draft and present in what was retrieved. An assertion with no citation attached never gets checked, since the draft only has to clear the "at least one citation somewhere" rule. For a tool whose entire job is to avoid stating unsupported law, that's a real hole. I'd ground each claim on its own, whether or not the model chose to cite it.

4. **Give escalation more than a regex**

    I'm upfront that pattern-matching is brittle, and it earns its place as a fast, safe-failing first pass. But the handful of phrases it knows cover a thin slice of how people actually ask for personal advice, and anything outside the list flows straight into the full pipeline. I'd add a cheap classifier behind the regex: the regex still runs first as a free, instant filter for the obvious phrasings, and the classifier catches the requests worded in ways the regex doesn't know. Right now the regex is the only thing guarding this exit, so anything it misses sails through.

If there's a single idea underneath all of this, it's that orchestration is where a safety-critical agent actually lives or dies. The model drafts the answer, but whether you can trust the result comes down to the graph around it: what runs, in what order, and what gets to decide whether an answer goes out at all.

---

### References

1. Anthropic's ["Building Effective Agents"](https://www.anthropic.com/engineering/building-effective-agents) — source for the routing, prompt chaining, and evaluator-optimizer pattern names
2. [LangGraph Graph API](https://docs.langchain.com/oss/python/langgraph/graph-api) — `StateGraph`, nodes, conditional edges
3. [Code](https://github.com/aishahsofea/ai-legal-tool/tree/agent-graph) — `agent/graph.py`, `agent/state.py`, `agent/query_lifecycle.py`, `agent/nodes/*.py`
