AI Agents — A Graph, Not a Model
The Large Refund Never Even Reached the Approval Queue
Goal
You stop the graph in front of something irreversible, and continue after a person changes a value or rejects. You build both ways of pausing and confirm the difference between them with numbers you counted yourself.
Why it matters
In LangGraph, pausing is built on top of saving. Pausing means "do not do the next step now; continue later from the state left behind", so if there is nowhere to leave it (a checkpointer), you cannot continue either. Yet even if you give a breakpoint without a checkpointer, no error is raised — the graph stops silently and returns a half state. In this lab you first see that with your own eyes.
What a person does after a pause is not only approval. They also reduce the amount and reject. A value changed with update_state goes in through that key's reducer, so a key that overwrites and a key that appends behave differently.
There are two ways to pause. interrupt_before pauses before entering a node, and interrupt() inside a node pauses at the exact place where it is called. The latter runs that node again from the beginning when resuming — meaning a side effect placed before it happens twice. In this lab you count for yourself how many times it is entered.
The grader does not trust the explanations you wrote. It actually imports your module, runs it with arbitrary amounts and reasons, and compares the paused position and the result of continuing with values the grader computes separately.
Steps
- In /root/work/aghitl/approve.py, create
AUTO_LIMIT = 100000,RISKY,needs_approval(amount, reason),State, three nodes (intake,auto_settle,settle),triageandbuild_graph(). It does not pause yet. - Add
build_forgotten()andrun_forgotten(amount, reason). Run a version that gives onlyinterrupt_before=["settle"]without a checkpointer, and write down what happens. - Add
SAVER = MemorySaver(),build_pausing(),thread(name)andstart_case(amount, reason, name)to make it really pause.start_casereturns{"next": ["settle"], "outcome": "", "paid": -1}. - Add
resume_case(name)to continue withinvoke(None, config). - Make
resume_case(name, amount=..., decision=...)continue after a person changes the amount or rejects. On rejection, not a single won goes out. - Add a
confirmnode andbuild_asking()andask_and_answer(amount, reason, answer, name). You pause inside the node withinterrupt()and continue withCommand(resume=답)(the placeholder stands for the answer). You count how many times the node was entered withSIDE_EFFECTS. - Add
handle(...)to handle one case to the end and leave the requested value, the executed value and the approval status in one line. - Leave a record in /root/work/aghitl/approve_report.json and /root/work/aghitl/approve_report.md.
Notes
- Execution contract: the grader imports
/root/work/aghitl/approve.pyas a Python module and uses the names listed above directly. It is not run as a script. needs_approval(amount, reason)is true if the amount exceedsAUTO_LIMITor the reason is inRISKY.RISKY = ("파손", "오배송")(Korean words meaning "damaged" and "wrong delivery").- State keys:
amount,reason,decision,paid,outcomeandtrace.traceuses the appending reducer. A node name and a state key must not overlap — if they overlap, compiling givesValueError: 'x' is already being used as a state key. - Values the nodes leave:
auto_settlesetsoutcome="auto";settlesetsoutcome="settled"if approved, and if the state'sdecisionis"reject",outcome="rejected"andpaidis 0. If approved,paidis theamountat that point. - The names you write in
traceare the same as the node names (intake,auto_settle,settle,confirm), and the mark of a human edit is written ashuman. - Answer of
run_forgotten:{"raised": 참거짓, "trace": [...], "outcome": 문자열, "resumable": 참거짓}(the placeholders stand for a boolean and a string). Run it yourself and fill it in as you saw it. - Answer of
start_case:{"next": [...], "outcome": 문자열, "paid": 정수}(the placeholders stand for a string and an integer).nextisget_state(config).nextturned into a list. - Answer of
resume_case:{"outcome": 문자열, "paid": 정수, "trace": [...]}. - Answer of
ask_and_answer:{"next": [...], "asked": [...], "entered_before": 정수, "entered_after": 정수, "outcome": 문자열, "paid": 정수, "decision": 문자열}.askedholds the values ininterruptsofget_state(config).tasks. If the answer is"yes"it is approval, otherwise rejection. - Answer of
handle:{"approval": "not_required"|"approved"|"rejected", "outcome": 문자열, "paid": 정수, "requested": 정수, "changed_by_human": 참거짓}. - If you reuse the same
thread_id, the earlier records remain. The grader passes a different name each time. - A value changed with
update_statesends the branch through again. That value is recorded as written by the node that ran last, so that node's conditional edge is evaluated again. So if you reduce the amount of a case waiting for approval to belowAUTO_LIMIT, that case leaves the approval path and drops intoauto_settle. The grader reduces only from above the limit, so this lab does not deal with that rewiring, but you should know about it. - This Pod has no internet. langgraph 0.2.60 is already installed.
- Official docs: Interrupts · Persistence · Types reference
- Common mistakes: giving a breakpoint without a checkpointer, not giving a
thread_id, putting the original input in again when continuing (then it runs again from the beginning — giveNone), and putting something irreversible beforeinterrupt().
Separate the work a person must look at
In /root/work/aghitl/approve.py, create AUTO_LIMIT = 100000, RISKY, needs_approval, State, three nodes, triage and build_graph(). A small amount with a simple reason goes to auto_settle, and otherwise to settle. It does not pause yet.
If you write the criterion in one place, needs_approval, changing the policy means fixing that function. triage returns a branch name (auto, hold), and which node it goes to is decided by the path map. Name the nodes differently from the state keys.
If you forget the checkpointer, it disappears silently
Add build_forgotten() and run_forgotten(amount, reason). Run a version that gives only interrupt_before=["settle"] without a checkpointer, and write down whether an exception was raised, how far it got, and whether it can be continued, as {"raised": ..., "trace": [...], "outcome": ..., "resumable": ...}.
Run it yourself and write what you saw. It will be different from what you expect — that surprise is the whole point of this step. Nothing was saved, so you cannot continue with invoke(None, ...) either. Why this is dangerous lies in the fact that there is not a single error in the log.
Really pause
Add SAVER = MemorySaver(), build_pausing(), thread(name) and start_case(amount, reason, name). A case that needs approval pauses before settle, and start_case returns {"next": ["settle"], "outcome": "", "paid": -1}.
Three things have to be present together — a checkpointer, a thread_id and a breakpoint. get_state(config).next being non-empty means "it is not finished yet". A small case must not pause and must simply finish, so what matters is that there is a branch that does not pass through the node where you put the breakpoint.
Continue from where it was left
Add resume_case(name) to continue with invoke(None, config). The answer is {"outcome": "settled", "paid": 금액, "trace": [...]} (the placeholder stands for the amount).
Giving None in the input slot means "there is no new input. Continue from where it was saved." If you put the original input in again, it runs again from the beginning, and then the footprints double and it shows. You have to use the same thread_id for it to continue.
The approver changes the amount or rejects
Make resume_case(name, amount=..., decision=...) fix the state first with update_state and then continue. If decision="reject", outcome is rejected and paid is 0. Leave the mark of the edit in trace as human.
The values update_state puts in also go through that key's reducer. A key that overwrites, like amount, is overwritten, but a key that appends, like trace, accumulates — so it is suitable for leaving one line of a mark. Rejection must also be a result. If you let the approver reject by "just not pressing", that case stays in the queue forever.
If you pause inside a node, that node runs twice
Add a confirm node and build_asking() and ask_and_answer(amount, reason, answer, name). Pause with interrupt() and continue with Command(resume=답) (the placeholder stands for the answer), and count with SIDE_EFFECTS how many times the node was entered.
The value interrupt() returns is the answer the person gave. When you resume, that node runs again from the beginning, so things placed before interrupt() happen twice — count it yourself to confirm. The value you passed when pausing is in interrupts inside get_state(config).tasks.
Record the requested value and the value that went out together
Add handle(amount, reason, name, amount_override=None, decision=None) to handle one case to the end and return {"approval": ..., "outcome": ..., "paid": ..., "requested": ..., "changed_by_human": ...}.
The core of an approval record is not the person's name but the changed value. If the requested amount and the amount actually sent out differ, that fact must be in the record so that you can explain it later. A case that needs no approval is written as not_required and does not pause.
Record what you saw
Write auto_limit, forgotten, small, large, trimmed, rejected and confirm_entered in /root/work/aghitl/approve_report.json, and write /root/work/aghitl/approve_report.md in four sections: ## 어디서 멈추기로 했나 (where you decided to pause), ## 체크포인터를 빠뜨리면 (if you leave out the checkpointer), ## 사람이 고친 값은 어디로 들어가나 (where a value a person changes goes in) and ## 노드 밖에서 멈추기와 노드 안에서 멈추기 (stopping outside a node and stopping inside a node).
forgotten is the answer of run_forgotten as it is, and small, large, trimmed and rejected hold only the three keys approval, outcome and paid from the answer of handle. confirm_entered is {"before": 정수, "after": 정수} (the placeholders stand for integers), the values ask_and_answer counted. Do not make them up; get them by running.