Build a Self-Correcting Documentation Agent With LangGraph and Ollama
A local reflexion pipeline where one model drafts Python API docs, a second critiques them and a third reconciles the result, wired as a LangGraph loop on Ollama.
This article is published in English.
Sources: Build a Self-Correcting AI Agent with LangGraph and Ollama
The pattern goes by the name reflexion. Its premise is that LLMs spot flaws in text far better than they write flawless text in one go, so the work is divided into roles. One detail matters a lot: each role uses a different local model. A model that both writes and grades tends to wave its own output through, and a separate critic avoids that.
Earlier from-scratch agents built without a framework made a case that tools like LangGraph pay off once you've built the thin version by hand. This time LangGraph is a deliberate choice, since a revision loop with conditional exits is exactly the cyclic workflow it was made for. Ollama serves the models. The defaults are qwen3.5:9b as generator, deepseek-coder:6.7b as critic and llama3:8b as adjudicator, but a small JSON file assigns the roles, so anything you've pulled locally can stand in.
The outcome is one console command, reflexion. It reads a source file, runs the loop, prints a scorecard for each round and writes the polished Markdown to disk. The entire pipeline fits in a single Python file of roughly 360 lines.
The build has seven pieces: the workflow state and the critic's evaluation schema, the Generator node, the Critic node, the router that decides between looping and exiting, the Adjudicator node, the graph wiring, and finally the runtime with configuration and output cleanup.
Setting up
The code lives in a repository called local-reflexion-agent, as one package, src/reflexion/main.py, exposed as a console script through pyproject.toml. Clone it and install in editable mode:
git clone https://github.com/jfjensen/local-reflexion-agent.git
cd local-reflexion-agent
pip install -e .
That brings in langgraph, langchain-core, langchain-community, langchain-ollama and pydantic, and registers a console script named reflexion. Then confirm Ollama is running locally and pull the three models named in config.json:
ollama pull qwen3.5:9b
ollama pull deepseek-coder:6.7b
ollama pull llama3:8b
Different models are fine. The roles are assigned in the config.json at the repository root, covered again in the runtime section below. The repository includes three small sample files in input/, so the pipeline can run straight away:
reflexion --input input/source_function_a.py --output output/result.md --config config.json
Part 1: State and the critic's schema
Two data structures come first. One defines what the critic must return and the other defines the memory that travels through the graph.
The critic's output is the more interesting one. Ask a local 6.7B model for a score and it will cheerfully answer with a paragraph of prose. What's needed is numbers plus one actionable fix, so a Pydantic schema forces the model to fill in fixed fields:
class DocEvaluationSchema(BaseModel):
"""Pydantic schema used to force a structured JSON schema constraint out of our local critic model.
Deliberately contains only judgment fields: the overall average and the pass/fail decision are computed in code."""
technical_accuracy: int = Field(description="Score from 0-100 on accuracy.")
completeness: int = Field(description="Score from 0-100 covering parameter definitions.")
single_critical_fix: str = Field(description="The single absolute highest priority correction required.")
Some points about the schema:
- The
single_critical_fixfield is the core of the loop. In place of a list of everything wrong, the critic names the one top-priority correction. A small generator copes much better with "fix this one thing" than with a wall of mixed feedback. - What's left out matters as much as what's in. An earlier version also asked the model for an overall percentage and a pass/fail flag, which turned out to be a mistake, described under the critic. In short: never have a model do arithmetic or a threshold comparison that Python handles in a single line. The schema holds only judgments a model is really needed for, while the critic node does the averaging and the pass/fail decision itself, in plain code.
- Don't dismiss the
Fielddescriptions as decoration. They travel to the model inside the JSON schema constraint and work as tiny prompts.
State is next. LangGraph passes one state object from node to node, and every node answers with a partial update to it. A TypedDict declares its shape:
def append_scores(old: List[int], new: List[int]) -> List[int]:
return old + new
class AgentWorkflowState(TypedDict):
"""Memory tracking state for the heterogeneous local graph layout."""
source_code: str
generated_markdown: str
current_score: int
score_history: Annotated[List[int], append_scores]
required_fix: Optional[str]
iteration_count: int
is_approved: bool
final_adjudicated_markdown: Optional[str]
The one non-obvious line is score_history. The type Annotated[List[int], append_scores] tells LangGraph to merge updates to that field with the append_scores reducer instead of overwriting it. Each score the critic returns is therefore appended to the history. By the end of a run the full score curve across all revision rounds is available, which proves to be the most informative line the pipeline prints.
Part 2: The Generator node
A LangGraph node is nothing more than a function: the state goes in and a dictionary of updates comes out. The Generator has two modes. The first pass drafts documentation straight from the code. Each later pass revises the previous draft in response to the critic's fix.
def content_generator_node(state: AgentWorkflowState) -> dict:
"""The Generator node: Focuses entirely on drafting/editing documentation text layout."""
current_iteration = state.get("iteration_count", 0)
target_model = MODEL_REGISTRY["GENERATOR"]
# ... console logging trimmed, see the repo for the full version
llm = ChatOllama(model=target_model, temperature=0.7)
if not state.get("generated_markdown"):
system_prompt = "You are an expert technical writer. Convert raw code snippets into clean, comprehensive API markdown documentation."
user_prompt = f"Generate Markdown documentation for the following source code:\n\n{state['source_code']}"
else:
system_prompt = "You are a precise editor rewriting technical documentation to fix a structural flaw."
user_prompt = (
f"Your previous documentation draft was scored {state['current_score']}/100.\n"
f"You must completely rewrite the documentation to fix this specific issue: {state['required_fix']}\n\n"
f"Original Code Context:\n{state['source_code']}\n\n"
f"Previous Draft:\n{state['generated_markdown']}"
)
messages = [SystemMessage(content=system_prompt), HumanMessage(content=user_prompt)]
response = llm.invoke(messages)
return {
"generated_markdown": response.content,
"iteration_count": current_iteration + 1
}
What the code does:
Choosing the mode. If generated_markdown is empty, that means round one, so the model plays a technical writer starting from raw source. If a draft exists, the model is prompted as an editor and receives the previous score, the critic's single fix, the original source and its own previous draft.
The revision prompt. Telling the model its prior score is a small trick that works. A concrete "you scored 78/100" pushes it to change things instead of lightly rephrasing. The instruction asks for a complete rewrite to fix one specific issue, and narrow instructions like that suit small local models nicely.
Temperature. The generator runs at temperature=0.7, which gives the drafts some variety between rounds. The critic, as seen next, runs at zero.
The return value. Only changed fields come back. LangGraph merges them into the state, and iteration_count rises by one per generation round.
Part 3: The Critic node
This is a different model, deepseek-coder:6.7b by default, and its prompt took more attempts to get right than anything else in the build.
def content_critic_node(state: AgentWorkflowState) -> dict:
"""The Critic node: Cross-checks generator output using a different model with strict JSON formatting."""
target_model = MODEL_REGISTRY.get("CRITIC")
llm = ChatOllama(model=target_model, temperature=0.0)
structured_llm = llm.with_structured_output(DocEvaluationSchema)
system_prompt = (
f"You are a meticulous QA inspector evaluating API documentation against its source code. "
f"You are grading the DOCUMENTATION, not the source code. Never deduct points because the source code "
f"itself could be designed better, and never ask for the source code to be changed. "
f"Score by deduction: start from 100 and subtract points only for concrete errors you can verify by "
f"pointing at specific documentation text that contradicts the source code. "
f"Check that every 'raise' statement in the source is documented with its exact exception type and its "
f"exact quoted error message: a missing, denied, or misquoted exception is a major deduction. "
f"Recompute every numeric value shown in the documentation's examples against what the source code would "
f"actually return: a wrong number is a major deduction. "
f"If you find no verifiable errors, score 100. "
f"The single_critical_fix must point at the documentation text to change, not at the source code."
)
user_prompt = (
f"Evaluate the following markdown documentation relative to its raw source code.\n\n"
f"Raw Source Code:\n{state['source_code']}\n\n"
f"Generated Markdown Document:\n{state['generated_markdown']}"
)
evaluation: DocEvaluationSchema = structured_llm.invoke([
SystemMessage(content=system_prompt),
HumanMessage(content=user_prompt)
])
# The model provides the judgment scores; the arithmetic and the pass/fail decision are done here in code.
overall_score = round((evaluation.technical_accuracy + evaluation.completeness) / 2)
is_approved = overall_score >= QUALITY_SCORE_BAR
required_fix = sanitize_critical_fix(evaluation.single_critical_fix)
return {
"current_score": overall_score,
"score_history": [overall_score],
"required_fix": required_fix,
"is_approved": is_approved
}
Structured output. llm.with_structured_output(DocEvaluationSchema) is the line that counts. It constrains the model so that a validated DocEvaluationSchema object comes back instead of free text. That means no JSON parsing, no regex and no retry logic for malformed output. The critic runs at temperature=0.0, because a grader should be deterministic or as close to that as a local model gets.
How the prompt evolved. It went through three versions, and the history is probably the most transferable lesson here.
- The first version politely asked the model to analyse the documentation and score it. The result was courteous scores in the high 80s and 90s for documents containing invented error messages, so the loop quit too early.
- Version two swung the other way, telling the model to act as "an exceptionally harsh QA inspector" and to "tank the score aggressively" whenever it saw a problem. The critic then rated a substantially correct draft 0 out of 100 in four consecutive rounds, and its round-one reasoning misread the source outright. Small critics don't calibrate; they follow the tone they're given. Encourage generosity and they approve everything; demand harshness and they zero everything.
- The last version steers by procedure instead of tone. Scoring is by deduction from a starting 100, subtracting only for errors that can be confirmed by pointing at specific text in the documentation. Nothing verifiably wrong means a score of 100. With the scoring mechanics pinned down, the model has little room left to express a mood.
- The prompt also says outright that the critic grades the documentation, not the function. In several runs the "critical fix" had been a critique of the source's design, such as missing validation or weak cryptography, which the generator can do nothing about since its job is describing the code as it stands.
- Two checks are spelled out by name: the exact exception types along with their quoted messages, and a recomputation of every number in the examples. These were the error classes vaguer prompts let through in testing. A small critic responds far better to a named list of checks than to a general request for accuracy.
Keep the arithmetic out of the model. The first schema had the model produce an overall percentage and a pass/fail boolean itself, and the outputs were odd. Once the critic gave technical accuracy 100 and completeness 100 and then reported a "net average" of exactly 95, which was simply the pass bar mentioned in its prompt, not an average of anything. Another time it gave a draft 97 against a bar of 95 on four rounds straight yet marked it as failing each time. All four attempts were wasted on drafts that already qualified, because the router believed the boolean and the log calmly stated that 97 fell short of 95.
Today the model supplies only the two sub-scores. Three lines of Python do everything else: compute the average, compare it with QUALITY_SCORE_BAR and set the flag that the router follows. Comparing a number to a threshold isn't a matter of judgment, and anything that isn't a matter of judgment shouldn't be delegated to a model.
The sanitize_critical_fix guard. During that same four-round run the critic's feedback in one round collapsed into several hundred repeated ../ tokens, and these were pasted word for word into the generator's next prompt as the issue to repair. Small models working under structured-output constraints occasionally behave like this. The guard tests whether the fix is empty or degenerate (a single repeated token, or text made of only a few distinct characters) and, if so, swaps in a generic request to recheck the documentation against the source. It isn't sophisticated, but it keeps junk from steering an entire round. The whole function is short:
def sanitize_critical_fix(fix: Optional[str]) -> str:
"""Guards against degenerate critic feedback (empty strings or runaway token repetition)."""
stripped = (fix or "").strip()
words = stripped.split()
is_degenerate = (
not stripped
or len(set(stripped)) <= 4
or (len(words) > 5 and len(set(words)) <= 2)
)
if is_degenerate:
return "Re-verify the documentation line by line against the source code and correct the weakest section."
return stripped
Each round the score goes back as a list holding a single element, and the state reducer defined earlier appends it to the history. That is how the curve accumulates. The node also prints a round scorecard showing technical accuracy, completeness, the net score against the bar and the critic's feedback. Those prints are cut from the excerpt but live in the repository, and following the scorecard from round to round is half the fun.
Part 4: The router
Generator and critic are done, so something now has to choose what follows each critique. In LangGraph that job belongs to a routing function, which reads the state and returns the name of the next node.
def execution_router(state: AgentWorkflowState) -> Literal["content_generator", "content_adjudicator"]:
"""Monitors workflow states and determines whether to repeat, loop, or step out to adjudication."""
if state["is_approved"]:
return "content_adjudicator"
if state["iteration_count"] >= MAX_ATTEMPTS:
return "content_adjudicator"
return "content_generator"
Notes on the router:
- The loop has only two exits: the critic approves the draft, or the attempt limit is reached. Either way the draft proceeds to the Adjudicator; in all other cases it goes back to the Generator.
- That limit deserves more respect than it seems to. With a 95-point bar and a strict critic, certain drafts will never pass, and with no
MAX_ATTEMPTSthe loop would keep calling Ollama indefinitely. Four attempts is an arbitrary but workable figure, and it can be changed. - The
Literalreturn type is more than documentation, since it matches the routing map defined when the graph is wired. - As with the other nodes, console prints are trimmed from the excerpt. In the repository the router logs the attempt count and score history before each decision.
Part 5: The Adjudicator node
Whatever leaves the loop, whether it passed or ran out of attempts, gets a final pass from a third model. The Adjudicator's task isn't to add content. It resolves contradictions that crept in across revision rounds and strips out anything conversational.
def content_adjudicator_node(state: AgentWorkflowState) -> dict:
"""The Adjudicator node: Runs outside the loop to resolve structural contradictions and finalize text."""
target_model = MODEL_REGISTRY["ADJUDICATOR"]
llm = ChatOllama(model=target_model, temperature=0.2)
system_prompt = (
"You are a senior technical documentation editor giving the final word on an API document "
"that has been through multiple revision rounds. Your job is to read the raw source code, "
"look at the latest draft, eliminate any conflicting statements, clean up semantic prose, and output the polished final markdown document. "
"Do not include any preamble, comments, conversational pleasantries, or explanations-return ONLY the markdown documentation."
)
user_prompt = (
f"Review and finalize this documentation template.\n\n"
f"Raw Source Reference Code:\n{state['source_code']}\n\n"
f"Latest Revision Draft:\n{state['generated_markdown']}\n\n"
f"Last Remaining Critic Concern:\n\"{state['required_fix']}\""
)
response = llm.invoke([SystemMessage(content=system_prompt), HumanMessage(content=user_prompt)])
return {"final_adjudicated_markdown": response.content}
Why the node exists. After several rounds of "completely rewrite to fix X", a draft can contain internal contradictions: one round adds a claim and a later rewrite contradicts it without removing it. The Adjudicator receives the source, the latest draft and the critic's last outstanding concern, with an instruction to eliminate statements that conflict. It also serves as the exit for drafts that never cleared the bar. Instead of shipping the raw fourth attempt, the pipeline at least delivers a harmonised version.
Temperature. It runs at temperature=0.2. That's low because editing is the job, but not zero because it must still rewrite prose.
Markdown only. Local models like to open with "Certainly! Here is your documentation:" and finish with "I hope this helps." The prompt bans both. Prompting alone proves insufficient, as the runtime section shows, so a post-processing step removes whatever slips through.
Part 6: Wiring up the graph
Now the pieces get assembled. This is where LangGraph earns its place, because the whole topology, cycle included, takes a handful of lines:
builder = StateGraph(AgentWorkflowState)
builder.add_node("content_generator", content_generator_node)
builder.add_node("content_critic", content_critic_node)
builder.add_node("content_adjudicator", content_adjudicator_node)
builder.set_entry_point("content_generator")
builder.add_edge("content_generator", "content_critic")
builder.add_conditional_edges(
"content_critic",
execution_router,
{
"content_generator": "content_generator",
"content_adjudicator": "content_adjudicator"
}
)
builder.add_edge("content_adjudicator", END)
agent_pipeline = builder.compile()
Nodes and entry point. The three node functions are registered under names, with the Generator as the entry point. The Generator always hands over to the Critic, and that edge is unconditional.
The conditional edge. add_conditional_edges connects the router to the Critic. Once a critique finishes, the router runs and its answer is looked up in the mapping to find the next node. That mapping creates the cycle, because content_critic can lead back to content_generator, which feeds content_critic again.
Compilation. Calling builder.compile() yields a pipeline that can be run, so the entire loop becomes one agent_pipeline.invoke(initial_state) call.
Anyone who has hand-written this loop, with a while statement, manual state threading and an attempt counter, will notice how much bookkeeping vanished. The graph declaration also documents itself in a way a hand-rolled loop never does.
Part 7: Runtime, configuration and output cleanup
Last is the run() function behind the reflexion console script. It parses arguments, loads configuration, runs the pipeline and cleans the output. Much of it is argument parsing best read in the repository, but two pieces deserve a look.
The configuration comes first. config.json supplies the model roles and the loop settings:
{
"model_registry": {
"GENERATOR": "qwen3.5:9b",
"CRITIC": "deepseek-coder:6.7b",
"ADJUDICATOR": "llama3:8b"
},
"quality_score_bar": 95,
"max_attempts": 4
}
Should the file be absent or unreadable, the loader uses these same defaults, so the pipeline starts regardless. Default locations for input, output and config are resolved relative to the installed package, which for an editable install means the repository root. Running plain reflexion from any directory therefore finds the repository's config.json and input/ folder, while --input, --output and --config override each of them.
Second, the output cleanup, which working with local models made necessary:
raw_final_text = final_output.get("final_adjudicated_markdown", "")
clean_final_markdown = raw_final_text
# 1. Clean Preamble (Conversational text at the top)
if "#" in raw_final_text:
parts = raw_final_text.split("#", 1)
if len(parts[0].strip()) > 0 and len(parts[0].strip()) < 200:
clean_final_markdown = "#" + parts[1]
# 2. Clean Postscript (Conversational and meta review text at the bottom)
meta_prefixes = (
"note:", "note that", "i hope", "this concludes", "in conclusion",
"no formatting errors", "no contradictions", "no errors",
"let me know", "feel free", "please let me know"
)
meta_keywords = ("errors or contradictions", "contradictions were found", "errors were found")
lines = clean_final_markdown.rstrip().split("\n")
while lines:
last_line = lines[-1].strip()
if not last_line:
lines.pop()
continue
lowered = last_line.lower()
# Real content usually carries Markdown structure; meta chatter does not.
is_structural = lowered.startswith(("#", "|", "-", "*", ">", "`")) or lowered[0].isdigit()
is_meta = (not is_structural) and (
lowered.startswith(meta_prefixes) or any(k in lowered for k in meta_keywords)
)
if not is_meta:
break
lines.pop()
clean_final_markdown = "\n".join(lines).rstrip() + "\n"
How it behaves:
- The preamble check. If text precedes the first
#heading and runs under 200 characters, it's almost certainly a "Here is the finalized document:" opener and gets cut. If it's longer than 200 characters it stays, because it could be genuine content. - The postscript sweep. Working from the last line upward, the code inspects, line by line. A line is removed if it looks like model chatter instead of content: a leftover review verdict, or a sign-off beginning "Note:" or "I hope" such as "No formatting errors or contradictions were found.", which one test run left at the end of an otherwise decent document. Because it's a loop, not a single check, several stacked sign-off lines disappear in one pass.
- The structural guard. A line starting with a Markdown marker (heading, table row, bullet, blockquote, code fence or digit) is never touched. So a table row that happens to say "no errors" is safe; only plain prose lines can be removed.
- It's crude string surgery rather than anything smart, and some chatter will escape it. It does catch every pattern the models have actually produced in testing, and it costs nothing.
Once cleaned, the final Markdown is saved to the output path. The console then shows a summary of the run, including the whole score history and the number of loop turns.
Trying it
Three sample inputs ship with the repository: a function that filters metrics, an API signature check and a compound-interest calculator. Documenting the first one looks like this:
reflexion --input input/source_function_a.py --output output/result.md --config config.json
From there the loop is visible as it runs. Each round the Generator announces itself, the Critic prints a scorecard and the Router says whether it is looping or leaving.
Results from runs on all three inputs sit in the output/ folder, so the pipeline's products can be inspected without running anything: tidy parameter tables, descriptions of return values and tables of raised exceptions with their exact quoted messages. Two of the three files contain a small slip of the kind described below, a stated output that differs from what the code returns and an example that raises where it should return. They've been left in deliberately, so finding them makes a decent exercise.
When it goes wrong
It does go wrong, and the list below comes from failures seen across test runs. In one, the documentation named the wrong exception type and an invented error message, though the source raises a TypeError with a specific string. In another, it claimed that nothing is explicitly raised. A version-history table featured a made-up "0.1 initial release" row dated "TBD", neither of which appears anywhere in the source. An example printed a yield ratio near 0.86 when the right answer is 0.667. And one decent document ended on the critic's own verdict, "No formatting errors or contradictions were found."
Then there was the run that scored 97 against a 95 bar four rounds running while insisting the draft was not ready, with one round of feedback degrading into hundreds of ../ tokens. And once the critic prompt was pushed too far toward harshness, it scored a mostly correct draft 0 out of 100 four rounds in a row, defending the score with a plainly false statement about the source. The critic is neither a fixed judge nor a calculator, and it magnifies whatever mood the prompt gives it.
Fortunately the failures cluster, and each cluster has a targeted mitigation in the code. Content failures, such as wrong exceptions and wrong arithmetic in the docs, are handled by the critic prompt's named verification checks. Calibration failures (polite rubber-stamps at one end, floored zeros at the other) are handled by the deduction-based scoring procedure and by moving the average and threshold decision out of the model and into the critic node's Python. Output failures, sign-offs and leftover review verdicts, are handled by the postscript sweep, and degenerate feedback by the sanitize guard.
None of it makes the loop foolproof, since a prompt instruction is only a request and not a proof. When scores still swing after all these measures, the most powerful lever is the model rather than the prompt. The critic has the toughest job in the registry, and replacing the CRITIC entry in config.json with a stronger general-purpose model is a one-line change that needs no code.
The loop dependably produces a far better document than a one-shot prompt to the same generator would, and the score history gives an honest record of how it got there. If runs keep hitting the attempt cap, two settings are worth adjusting: quality_score_bar, which can drop below 95, and max_attempts, which can rise above 4.
Putting it together
The result is a single-file pipeline of about 360 lines that:
- A shared state, plus a structured evaluation schema for the critic, with the averaging and the pass decision computed in Python and not by the model.
- A draft written by one local model and critiqued by another, so that no model grades its own work.
- A router that returns drafts for focused revision until the quality bar is met or the attempts run out.
- Runs a third model as final adjudicator to resolve contradictions left by the revision rounds.
- Strips conversational preambles and postscripts from the final Markdown before writing it to disk.
- Settings held entirely in a small
config.json, so changing models or relaxing the loop takes no code edits.
There's room to extend it. The critic could score several rubric dimensions, with the router choosing the weakest to fix, in place of a single critical fix. A fourth node could run the documented code's examples and feed real errors back into the loop, catching the hallucinated claims the critic misses. Or the same Generator, Critic and Adjudicator shape could be pointed at a different task entirely, such as translation or test generation, since nothing in the graph is specific to documentation.
The argument for frameworks holds here: they earn their weight once a workflow has genuine cycles, branches and shared state, and this pipeline fits that description; LangGraph took care of the parts nobody wants to hand-roll, namely state merging, conditional routing and the loop itself. Building thin versions first makes it clear which parts of the framework are doing real work, and that order is worth recommending.