AI Agents — A Graph, Not a Model
Raising the Limit Will Not Finish That Case
Goal
You build a graph with a loop yourself, measure with your own hands what the recursion limit counts, and record hitting the limit and finishing the job as different results. Finally you build a diagnosis that sifts out inputs that cannot reach the ending condition.
Why it matters
In LangGraph a loop is not special syntax but just an edge that goes backward. That is why it is easy to build and hard to end. Even when an ending condition exists, if one input that cannot reach that condition gets mixed in, that case loops forever — the agent in this lab is exactly like that. There are two reasons the score gets cut (forbidden words and length), but the node that fixes things fixes only one.
The recursion limit is the last line of defense in such a case, but if you do not know the unit it counts, you misdiagnose. What the limit counts is not the number of node executions but supersteps, and nodes placed side by side, however many, are one superstep. You measure and confirm that yourself in this lab.
And an exception raised by hitting the limit and a graph ending by its own decision are completely different events. If you record both in the same "failure" box, you cannot later tell what the problem was. Set the limit in two layers (rounds in the state and recursion_limit), and make the inner limit leave a result.
The grader does not trust the explanations you wrote. It actually imports your module, pokes at the rule functions with arbitrary sentences, runs the graph with arbitrary limits, and compares the results with values the grader computes separately.
Steps
- In /root/work/agroute/route.py, create
BANNED,MIN_LEN,PASS,make_draft,score_of,revise_text,State, four nodes (compose,review,revise,ship),after_reviewandbuild_graph(). Give the conditional edge that branches atreviewa path map; at this step, what followsreviseis END. - Add an edge that returns from
revisetoreviewto make a loop. An inquiry with several forbidden words must go around several times and pass. - Add
build_unbounded()andrun_unbounded(question, limit). Run a version with the ending condition deliberately removed under the given limit, and if it hits the limit, return{"error": "GraphRecursionError", "limit": 한도, "message": 예외 메시지}(the placeholders stand for the limit and the exception message). - Add
MAX_ROUNDS = 5and anescalatenode so thatbuild_graph()ends without an exception for any input. When it reaches the limit,outcomeisescalate. - Add
chain_graph(n),fan_graph(n)andmin_limit(app, state).min_limitfinds the smallestrecursion_limitneeded to run that graph to the end. - Add
run_guarded(question, limit)to record finishing and hitting the limit under differentstatusvalues. - Add
diagnose(question)to sift out whether this input can satisfy the rules, and if not, why (too_shortorbanned). - Record the numbers you measured and your judgments in /root/work/agroute/route_report.json and /root/work/agroute/route_report.md.
Notes
- Execution contract: the grader imports
/root/work/agroute/route.pyas a Python module and uses the names listed above directly. It is not run as a script. make_draft(question)isquestion.strip() + " 라고 안내드립니다."(the Korean phrase means "we inform you that").score_of(text)starts at 100 and deducts 30 each time a forbidden word appears and 40 if the length is underMIN_LEN, and never goes below 0.revise_text(text)removes only one forbidden word.- Constants:
BANNED = ("무조건", "절대로", "보장합니다")(Korean words meaning "unconditionally", "never" and "we guarantee"),MIN_LEN = 30,PASS = 70,MAX_ROUNDS = 5. - State keys:
question,draft,score,rounds,outcomeandtrace.traceuses the appending reducer. - A node name and a state key must not overlap. LangGraph puts both in the same namespace, so if you name the node that writes the draft
draft, compiling givesValueError: 'draft' is already being used as a state key. That is why the first node in this lab is namedcompose. after_reviewreturns a branch name (ship,revise,escalate). Which branch goes to which node is decided by the third argument ofadd_conditional_edges.chain_graph(n)andfan_graph(n)return a graph with n nodes. The node names are free, and each node writes its own name to a key that appends.min_limit(app, state)raises the limit from 1, triesinvoke, and returns the first limit that succeeds. Failure arrives aslanggraph.errors.GraphRecursionError.- Answer of
run_guarded: if it finished,{"status": "done", "rounds": 정수, "outcome": "ship"|"escalate", "score": 정수}(the placeholders stand for integers); if it hit the limit,{"status": "limit", "rounds": -1, "outcome": "", "score": -1}. - Answer of
diagnose:{"fixable": 참거짓, "reason": ""|"too_short"|"banned", "best_score": 정수, "rounds_needed": 정수}(the placeholders stand for a boolean and integers).best_scoreis the score after removing forbidden words until no more can be removed. - This Pod has no internet. langgraph 0.2.60 is already installed.
- Official docs: Use the graph API · GRAPH_RECURSION_LIMIT · Graph API overview
- Common mistakes: letting the condition function return a node name instead of a branch name and omitting the path map, putting the ending judgment in two places (the node and the condition function), raising the limit to get past only the symptom, and believing that nodes placed side by side use up the limit.
Separate branch names from the path map
In /root/work/agroute/route.py, create three rule functions, four nodes, after_review and build_graph(). Give the conditional edge at review the path map {"ship": "ship", "revise": "revise"}; at this step, what follows revise is END.
The condition function takes the state and returns a branch name. If you make it return node names directly, you have to fix the judging code every time you change the shape of the graph. How score_of deducts points is the material for the later steps, so follow the rules in the instructions exactly.
Send the fixed draft back to be scored again
Add an edge that returns from revise to review to make a loop. An inquiry with three forbidden words must go around three times and end up at ship.
A loop is not special syntax but just an edge that goes backward. The fact that revise removes only one forbidden word at a time decides the number of rounds. rounds, which counts the rounds, is raised by revise — that way there is a place to put the inner limit later.
See hitting the limit with your own eyes
Add build_unbounded() and run_unbounded(question, limit). Run a version with the ending condition deliberately removed under the given limit; if it hits the limit, return {"error": "GraphRecursionError", "limit": 한도, "message": 예외 메시지} (the placeholders stand for the limit and the exception message), and if it finished, return error as an empty string.
The exception is langgraph.errors.GraphRecursionError. The message contains the limit number as it is, so do not make it up; put str(예외) (the string form of the exception) in as it is. build_unbounded() must be a different graph from build_graph() — because this step must stay valid even if you add the limit to build_graph() in later steps.
An inner limit that leaves a result
Add MAX_ROUNDS = 5 and an escalate node, and make after_review return the escalate branch at the limit. Add that branch to the path map too. Now build_graph() must end without an exception for any input.
Reaching the outer limit (recursion_limit) is an exception, so the result disappears. The inner limit leaves a result — that is the point of this step. Put the judgment in after_review only. If the node also looks at whether to end, a day comes when the two places disagree.
What the limit counts is supersteps
Add chain_graph(n) (in a line), fan_graph(n) (side by side) and min_limit(app, state). min_limit finds and returns the smallest recursion_limit needed to run that graph to the end.
Raise it from 1, try invoke, and return the first value that succeeds. How the answers of the two graphs differ is the heart of this step — nodes placed side by side, however many, run in the same superstep. One key with a reducer is enough for the state.
Tell finishing from hitting the limit
Add run_guarded(question, limit=25). If the graph ended by itself, it returns {"status": "done", "rounds": 정수, "outcome": "ship"|"escalate", "score": 정수} (the placeholders stand for integers), and if it hit the limit, {"status": "limit", "rounds": -1, "outcome": "", "score": -1}.
If you record the two events in the same "failure" box, you cannot separate them later. All it takes is catching the exception and giving it a different name, but that one line makes a big difference in operation. If you give a small limit, even a normal input comes out as limit — the grader checks that.
Sift out inputs that do not get better however much you fix them
Add diagnose(question) so that it returns {"fixable": 참거짓, "reason": ""|"too_short"|"banned", "best_score": 정수, "rounds_needed": 정수} (the placeholders stand for a boolean and integers). best_score is the score after removing forbidden words until no more can be removed.
You can find out without running the graph — apply revise_text until it no longer changes anything and measure the score at that point. If that score falls short of PASS, this input will never pass however many times it runs. That is why a diagnosis is cheaper than raising the limit.
Report with the numbers you measured
Write default_recursion_limit, chain_min_limit, fan_min_limit, clean, dirty, stuck and stuck_reason in /root/work/agroute/route_report.json, and write /root/work/agroute/route_report.md in four sections: ## 고리를 어디에 두었나 (where you put the loop), ## 재귀 한도는 무엇을 세는가 (what the recursion limit counts), ## 상한에 닿은 것과 마친 것 (hitting the limit versus finishing) and ## 고쳐도 나아지지 않는 입력 (inputs that do not get better when fixed).
default_recursion_limit is the default when no config is given — do not make it up; raise the limit and measure to confirm. chain_min_limit and fan_min_limit are objects that use the number of nodes as a string key (for example {"1": ..., "2": ..., "3": ...}). clean, dirty and stuck each hold the answer of run_guarded as it is.