TT Lab
Get started
Learn Learning paths Courses

AI Agents — A Graph, Not a Model

The Large Refund Never Even Reached the Approval Queue

Continue in TT Lab

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

  1. In /root/work/aghitl/approve.py, create AUTO_LIMIT = 100000, RISKY, needs_approval(amount, reason), State, three nodes (intake, auto_settle, settle), triage and build_graph(). It does not pause yet.
  2. Add build_forgotten() and run_forgotten(amount, reason). Run a version that gives only interrupt_before=["settle"] without a checkpointer, and write down what happens.
  3. Add SAVER = MemorySaver(), build_pausing(), thread(name) and start_case(amount, reason, name) to make it really pause. start_case returns {"next": ["settle"], "outcome": "", "paid": -1}.
  4. Add resume_case(name) to continue with invoke(None, config).
  5. Make resume_case(name, amount=..., decision=...) continue after a person changes the amount or rejects. On rejection, not a single won goes out.
  6. Add a confirm node and build_asking() and ask_and_answer(amount, reason, answer, name). You pause inside the node with interrupt() and continue with Command(resume=답) (the placeholder stands for the answer). You count how many times the node was entered with SIDE_EFFECTS.
  7. Add handle(...) to handle one case to the end and leave the requested value, the executed value and the approval status in one line.
  8. Leave a record in /root/work/aghitl/approve_report.json and /root/work/aghitl/approve_report.md.

Notes

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.