Home / Articles / Structural Guardrails for AI Agents: Inside the ResolveFlow Pipeline

This article is published in English.

Structural Guardrails for AI Agents: Inside the ResolveFlow Pipeline

Explains how a LangGraph-based agent enforces separation between reasoning and execution through code-level checks rather than prompt instructions, including a retrieval bug that surfaced along the way.

3446 words

Most agentic AI demos follow the same basic pattern: a model decides on an action and then executes it immediately. A tool gets attached to a prompt, the model invokes it, and the tool runs without any further check. It can look convincing in a short demo clip, but this is precisely the setup that worries people who think about letting autonomous systems touch anything consequential — because often the only safeguard between a sound diagnosis and a harmful write to a live system is a warning sentence inside a prompt.

The project described here was built around avoiding that reliance on a single cautionary instruction.

ResolveFlow accepts a GitHub issue link, collects supporting evidence, sorts the issue into a category, and then, based on that category, does one of three things: carries out a fixed, non-negotiable action, starts an LLM-driven investigation anchored in retrieved evidence, or sends the issue directly to a human reviewer. Rather than a single loop where a model prompts a tool call, it is structured as a LangGraph state machine, built around one central idea:

Reasoning and execution stay separated because of how the system is built, not because of a convention it's asked to follow.

That means it isn't simply a case of instructing the model to check with someone first. Instead, a second, independent LLM call examines and critiques the first model's diagnosis before a human ever sees it. And the single function anywhere in the codebase that is permitted to write back to GitHub verifies an explicit approval flag from within its own logic — not because the graph is expected to route calls that way, but because the function itself will decline to run without that flag present, even if a future code change wired in a direct path that skipped the usual steps.

The rest of this walkthrough covers how the system is actually assembled, using the real implementation, along with a bug that turned up during development. That bug offers a useful lesson: a model pointing to a genuine source document is not the same thing as a model pointing to a source that is actually pertinent to the question at hand.

The shape of the pipeline

Six stages run in sequence, and just one of them is given permission to alter anything outside the pipeline itself:

  1. fetch_evidence — actual calls to the GitHub REST API, pulling the issue's body text, its comment thread, and any CI check runs.
  2. normalize_evidence — the unprocessed JSON returned by those calls gets checked and converted into a typed IssueEvidence object.
  3. classify — a lightweight, rule-driven step that involves zero LLM calls.
  4. generate_diagnosis — triggered only when the classification step flags the issue as needing deeper investigation. It's an LLM call combined with retrieval-augmented context and output constrained to a defined schema.
  5. independent_review — a second, standalone LLM call that evaluates the first call's output critically, backed by pass/fail conditions computed in plain code rather than judged subjectively.
  6. await_approval → execute — the point where the graph pauses for a human decision, followed, only if approved, by the one node with permission to publish anything back to GitHub.

The following sections unpack the pieces that matter most.

Classification: deliberately not an LLM call

Having an LLM readily available makes it tempting to route every decision through it, even the ones that don't call for that kind of reasoning. The classification step determines how much of the costly, higher-risk path — model-based diagnosis, retrieval calls, and eventual writes — any given issue is even allowed to enter. Because of that gatekeeping role, it needs to be inexpensive, quick, and entirely predictable in its own right:

def classify(state: GraphState) -> dict:
    if state["evidence"].has_failing_ci:
        return {"classification": "deterministic"}
    elif state["evidence"].is_information_sparse:
        return {"classification": "ai_investigation"}
    else:
        return {"classification": "human_review"}

The step produces three possible results, each demanding a different level of downstream trust:

  1. deterministic — a CI check that has failed is a clear-cut, mechanical signal on its own. There's nothing to investigate; the issue simply gets tagged and passed along to a maintainer.
  2. ai_investigation — used when the issue lacks enough detail to act on directly. This is the branch where the language model's abilities are genuinely useful, since it has to work out what's actually being requested.
  3. human_review — reserved for anything unclear or anything with a potentially large impact if handled incorrectly. Instead of allowing the system to guess in these cases, it hands the issue straight to a person.

It's worth noting that human_review, rather than ai_investigation, is the fallback choice. Whenever the system can't tell what's going on, it doesn't try to improvise a clever answer.

Diagnosis: retrieval, then a schema, not free text

Once an issue is routed into the ai_investigation branch, the generate_diagnosis step searches a Pinecone index containing about 2,750 chunks sourced from real, closed issues spanning four separate repositories (facebook/react, langchain-ai/langchain, microsoft/terminal, and vercel/next.js). It then sends those retrieved snippets to the LLM as supporting context for the call:

def generate_diagnosis(state: GraphState) -> dict:
    evidence = state["evidence"]
    query = f"{evidence.title}\n\n{evidence.body}"
    snippets = retrieve_evidence(query, k=3)
    snippet_block = "\n\n".join(
        f"[{s['id']}] (relevance: {s['score']:.2f}) {s['text']}" for s in snippets
    )

    prompt = (
        f"Issue: {evidence.title}\n{evidence.body}\n\n"
        f"Comments:\n{chr(10).join(evidence.comments) or '(none)'}\n\n"
        f"Evidence snippets (cite by id in square brackets):\n{snippet_block}"
    )

    llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
    structured_llm = llm.with_structured_output(Diagnosis)
    diagnosis = structured_llm.invoke([("system", _SYSTEM_PROMPT), ("human", prompt)])

    return {
        "diagnosis": diagnosis,
        "retrieved_ids": [s["id"] for s in snippets],
        "retrieved_scores": {s["id"]: s["score"] for s in snippets},
    }

A couple of details in this diagnosis step deserve a closer look.

First, the shape of the output isn't something extracted after the fact — it's guaranteed up front. Diagnosis is defined as a Pydantic model:

class Diagnosis(BaseModel):
    root_cause: str
    severity: Literal["low", "medium", "high"]
    missing_info: list[str] = Field(default_factory=list)
    recommended_next_steps: list[str]
    citations: list[str] = Field(
        default_factory=list,
        description="IDs of retrieved evidence/doc snippets that support each claim above",
    )

By calling .with_structured_output(Diagnosis), the model is forced into this exact structure while it's generating its answer. There's no regex trying to fish a root cause out of a block of prose afterward — when the call succeeds, what comes back is already a typed object, not text you have to interpret.

Second, citations aren't a matter of tone or confidence — they're constrained to be real identifiers. The system prompt states plainly that any citation must match one of the snippet IDs supplied to it; nothing invented, and nothing attached to a claim the snippet doesn't actually back up. That constraint seems like it should be sufficient on its own. It isn't — which is exactly why the next stage in the pipeline exists.

Independent review: the model explains, the code decides

This is arguably the most important architectural choice in the whole system.

The independent_review node fires off a second, entirely separate ChatOpenAI call, with its own prompt and no shared context with the call that produced the diagnosis. Its job is to critique that diagnosis.

Here's the key part, though: the actual decision to approve or escalate is never left to the model. It comes down to three booleans computed in ordinary Python, and the LLM's output is reduced to human-readable commentary that no approval logic actually depends on.

def independent_review(state: GraphState) -> dict:
    evidence = state["evidence"]
    diagnosis = state["diagnosis"]
    retrieved_ids = set(state.get("retrieved_ids", []))
    retrieved_scores = state.get("retrieved_scores", {})

    groundedness_ok = bool(diagnosis.citations) and all(
        citation_id in retrieved_ids
        and retrieved_scores.get(citation_id, 0.0) >= MIN_RELEVANCE_SCORE
        for citation_id in diagnosis.citations
    )
    risk_ok = diagnosis.severity in _ALLOWED_SEVERITIES  # {"low", "medium"}
    permission_ok = True  # comment/label are the only writes available today

    llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
    reasoning = llm.invoke(
        [("system", _SYSTEM_PROMPT), ("human", prompt)]
    ).content  # human-readable critique — not what the gate checks

    outcome = "approve" if (groundedness_ok and risk_ok and permission_ok) else "escalate_to_human"

    return {"review_result": ReviewResult(
        outcome=outcome,
        groundedness_ok=groundedness_ok,
        risk_ok=risk_ok,
        permission_ok=permission_ok,
        reasoning=reasoning,
    )}

This is the general pattern any trustworthy version of "LLM-as-judge" needs to follow: the model can explain itself, but the code makes the call.

If you ask one model to grade another model's work and then simply trust whatever verdict shows up in its response, you've effectively built a system whose reliability is capped by the very thing you're trying to verify.

In this design, the LLM's output is useful narration for a human reader, but the gate that actually matters can't be talked into a bad outcome through persuasive wording, because it isn't parsing anything the model wrote in order to decide.

One more detail worth flagging: high severity never appears in _ALLOWED_SEVERITIES. Any diagnosis marked high severity is escalated to a human automatically, no matter how well its citations check out. Being correct and being safe to auto-approve are simply not the same thing.

The bug: "grounded" isn't the same as "relevant"

This is where things got genuinely tricky in practice.

The original groundedness_ok check simply verified that a citation ID matched something in the retrieved set — a real ID, as opposed to a fabricated one. That looks like a reasonable check on paper. It wasn't quite enough.

During testing against a small corpus of 40 issues, a genuinely empty-body React issue was run through the pipeline (facebook/react#36932, "experimental_taintUniqueValue throws RangeError for large binary values"). Retrieval returned three chunks, all legitimate and all correctly identified — but none of them had anything to do with this particular bug. Working from that weak material, the model still produced a confident, specific-sounding diagnosis that was flatly wrong: it claimed a "React DevTools extension compatibility issue." Every citation passed the groundedness test cleanly. The diagnosis itself was still useless.

The fix was to stop treating "was retrieved" as a stand-in for "is relevant." tools/retrieval.py was updated so that each snippet now returns its cosine similarity score alongside its text:

def retrieve_evidence(query: str, k: int = 3) -> list[dict]:
    results = vector_store.similarity_search_with_score(query, k)
    return [
        {"id": doc.metadata["id"], "text": doc.page_content,
         "source": doc.metadata["source"], "score": score}
        for doc, score in results
    ]

I then checked what real similarity scores looked like against the live index, rather than guessing at a threshold. A query that closely matched genuine content in the corpus scored around 0.53 to 0.63. A query about something entirely absent from the four ingested repositories scored around 0.21 to 0.22. Based on that gap, independent_review.py now enforces a hard floor: every cited snippet must clear MIN_RELEVANCE_SCORE = 0.35. That threshold sits deliberately closer to the escalate side of the gap than the middle, because letting a good diagnosis get escalated by mistake is a much smaller cost than letting a bad one slip through as approved.

Alongside fixing the scoring logic, the retrieval corpus itself needed to grow. It started as 40 issues from a single repository, producing roughly 130 chunks — small enough that a random real-world issue often had no genuine topical match to retrieve in the first place. That was expanded to around 600 issues pulled from four real repositories, yielding close to 2,750 chunks.

With the larger corpus in place, the same taintUniqueValue issue now pulls back two authentic near-duplicate reports and produces an accurate, specific root cause: String.fromCharCode.apply blowing past the JavaScript engine's argument-count ceiling when handling large buffers. Notably, independent_review still escalates this case for human review, because its severity is classified as high, and high-severity findings always escalate by design regardless of how confident or correct the diagnosis is. Correctness doesn't buy an exemption from the risk gate.

The broader lesson here is that a citation pointing to a real, retrieved chunk ID is a necessary condition, but not a sufficient one. Claiming "the model cited something" is a different statement from claiming "the model cited something true and relevant to this bug," and a system that only verifies the former ends up looking careful while actually just rubber-stamping noise.

The approval step is a genuine pause, not a cosmetic loading state

Every stage described so far only proposes an action. Nothing gets written back to GitHub until one specific point in the pipeline. Everything upstream — classification, diagnosis, review — only ever proposes; nothing writes to GitHub until this single step.

The await_approval node assembles the exact comment (and, where relevant, the exact label) that would be posted, using identical logic regardless of whether the issue was routed through the deterministic branch or came from an approved ai_investigation diagnosis. It then calls LangGraph's interrupt():

def await_approval(state: GraphState) -> dict:
    proposed_action = _build_proposed_action(state)
    approved = interrupt({
        "classification": state["classification"],
        "proposed_action": proposed_action,
    })
    return {"proposed_action": proposed_action, "approved": bool(approved)}

Calling interrupt() does more than show a "waiting for approval" spinner while the process sits idle — it actually halts execution of the graph mid-run. Picking that run back up later requires a completely separate request to locate the same paused thread and continue from where it left off, using a call like result = await compiled_graph.ainvoke(Command(resume=True), config) with the same thread_id the original run used. For this to work at all, the graph's state has to survive across two unrelated HTTP requests, which rules out relying on plain in-process memory for storage.

Only once that resume happens does the execute node run. Two details are worth flagging here. First, the permission check lives inside the node's own code rather than being expressed purely as a graph edge — so if some future refactor accidentally introduced a shortcut edge straight into execute, this check would still catch it. Second, execute posts state["proposed_action"] exactly as it was approved; it never regenerates the comment afterward. Whatever a human signed off on is precisely what gets published.

def execute(state: GraphState) -> dict:
    if not state.get("approved"):
        raise PermissionError("execute() called without explicit approval")

    evidence = state["evidence"]
    action = state["proposed_action"]
    token = state.get("github_token")

    result = {
        "comment": post_comment(evidence.repo, evidence.issue_number,
                                 action["comment"], token=token)
    }
    if action.get("label"):
        try:
            result["label"] = add_label(evidence.repo, evidence.issue_number,
                                         action["label"], token=token)
        except requests.HTTPError as exc:
            result["label_error"] = str(exc)

    return {"execution_result": result}

Why the storage backend for paused runs matters more than it seems

The initial implementation relied on SqliteSaver, which persists state to a local file on the backend's own disk. That setup runs without issue on a developer's machine.

It fails in production in a way that's easy to miss: on a free-tier host such as Render, disk storage is not persistent across restarts. The process shuts down after a period of inactivity and, on the next incoming request, restarts inside a brand-new container.

Here's what that looks like concretely. A run reaches await_approval and pauses, waiting for a person to act. Before anyone clicks approve, the free instance goes idle and spins down. The next request then spins up a fresh container with a completely empty database. When Command(resume=...) runs, there's nothing left to resume — the paused thread's history has vanished without any error or warning.

The solution is AsyncPostgresSaver, backed by an actual Postgres database (Neon, in this setup) that exists independently of whatever container the app happens to be running in:

async with (
 AsyncPostgresSaver.from_conn_string(DATABASE_URL, serde=get_serde()) as saver,
 AsyncConnectionPool(DATABASE_URL, open=False,
 check=AsyncConnectionPool.check_connection) as pool),
):

Even if the container gets destroyed and rebuilt from scratch, every paused thread survives, as long as DATABASE_URL still points at the same database. The pause-and-resume mechanics of interrupt() don't change at all — only where that pause state actually lives changes.

Worth calling out separately: writes made after approval run under the identity of whoever approved them, not under some shared deployment credential. The live app lets any GitHub user sign in, and every read or write for that run then uses that person's own OAuth token. A call like post_comment(evidence.repo, evidence.issue_number, action["comment"], token=token) uses the approver's token, not a token that belongs to whoever happened to deploy the app.

This matters for more than authentication hygiene. It turns "the person who approved this posted it" into something GitHub itself can confirm by looking at the comment's author, instead of a claim the app's own interface is simply asserting.

It also lets GitHub's existing permission system do useful enforcement without any extra code: someone just browsing a repository they don't own can leave a comment, but attaching a label requires triage or write access on that repository. execute.py handles this gracefully — a failed attempt to add a label counts as a partial success rather than blowing up the entire request.

The calls to the LLM and the embedding model still run on the deployment owner's own API keys no matter who is triggering them, which is exactly why a per-user daily rate limit exists to keep that cost bounded.

There's a distinction worth being explicit about: a regression eval and a capability eval are testing fundamentally different things, and grading them the same way is a mistake it's easy to fall into. The routing precedence inside classify(), the gate logic inside independent_review(), and the permission check inside execute() each have exactly one correct behavior, and the only acceptable pass rate for that kind of test is 100 percent, full stop. A safety gate that gets bypassed even once across any number of runs is a critical failure — not a number you smooth out by averaging.

That standard is completely different from judging whether generate_diagnosis produces good diagnoses. That kind of evaluation is graded by a model, does genuinely improve over time, and was never realistically going to reach 100 percent. Treating both kinds of checks as if they belonged on the same scale is a common trap — a safety gate that "mostly" works isn't functioning as a safety gate at all.

What's actually real right now

It's better to understate this than to oversell it, so here is the plain state of things rather than a pitch:

Evidence gathering runs on real GitHub REST calls, validated into typed objects. Classification is rule-based and deterministic, with no LLM involved. Diagnosis comes from an actual OpenAI call producing structured output, grounded in genuine Pinecone retrieval over roughly 2,750 chunks, re-ingested weekly. Independent review is a separate OpenAI call, but the actual gate enforcing anything is code, not the model's own opinion. The human approval gate is a genuine interrupt() pause, resumed through Command(resume=...), and verified byte-for-byte end to end. The frontend and backend are both deployed — on Vercel and Render respectively — fully asynchronous, and checkpointed to Postgres. GitHub OAuth lets any visitor sign in with their own account; writes then run as that person, capped by a daily limit. The eval suite covers safety-gate regression tests that require a 100 percent pass rate and are graded by code. A capability eval for how good the diagnoses actually are hasn't been built yet. Tests in the conventional sense haven't been written yet.

Those last two gaps aren't being hidden — they sit on the roadmap because they are genuinely the most valuable things to build next, not because they were overlooked.

Lessons worth carrying to your own project

Building an agent meant to act on real systems surfaces a handful of principles that go beyond this particular GitHub issue tool:

Keep reasoning and execution on separate code paths, not just separate prompts. An instruction like "always ask before acting" is still just a behavior, and behaviors have a way of breaking down at the exact moment you need them to hold. A function that flatly refuses to run unless a verified flag is set is a real boundary, not a suggestion.

When you say a review step is "independent," make sure that's literally true: a distinct call, no shared trace, and a verdict produced by code rather than by text the model itself generated. The model can explain its reasoning, but it shouldn't be the one grading it.

Don't let "the model pointed to something real" substitute for "the model pointed to something relevant." Actually measure retrieval quality against real queries before you settle on a similarity threshold.

If a human-in-the-loop pause matters to your design, explicitly test what happens when the process restarts before that human responds. In-memory state and ephemeral disks will look fine in local testing and then fail exactly where failure is most costly.

Finally, be upfront about what's genuinely finished versus what's still a stand-in. A status table that admits its gaps earns more trust than a README that quietly implies everything is done.