TT Lab
Get started
Learn Learning paths Courses

AI Agents — A Graph, Not a Model

Raising the Limit Will Not Finish That Case

Continue in TT Lab

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

  1. 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_review and build_graph(). Give the conditional edge that branches at review a path map; at this step, what follows revise is END.
  2. Add an edge that returns from revise to review to make a loop. An inquiry with several forbidden words must go around several times and pass.
  3. Add build_unbounded() and run_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).
  4. Add MAX_ROUNDS = 5 and an escalate node so that build_graph() ends without an exception for any input. When it reaches the limit, outcome is escalate.
  5. Add chain_graph(n), fan_graph(n) and min_limit(app, state). min_limit finds the smallest recursion_limit needed to run that graph to the end.
  6. Add run_guarded(question, limit) to record finishing and hitting the limit under different status values.
  7. Add diagnose(question) to sift out whether this input can satisfy the rules, and if not, why (too_short or banned).
  8. Record the numbers you measured and your judgments in /root/work/agroute/route_report.json and /root/work/agroute/route_report.md.

Notes

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.