Repository navigation
LangChain Intermediate · FreightDesk: one incident, nine fixes #36
akash-coded
started this conversation in
Hands-on Labs
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
FreightDesk: One Incident, Nine Fixes
Language: Python
Topics: LCEL chains,
@tooldesign,create_agent,InMemorySavercheckpointer, human-in-the-loop / PII / summarization middleware, tool exception handling, memory failure handlingLevel: Intermediate
1. The case
Meridian Air Cargo, exception desk, 06:14.
You are building FreightDesk, the assistant that works this case.
You will build it the way it actually gets built: naive version first, then fix what breaks. Each fix creates the next problem. That chain is the exercise.
2. The chain
Read this table before you write a line. It is the whole lab on one page.
ToolRetryMiddleware, scopedwrap_tool_callsafety net and guardcreate_agent+InMemorySaver+thread_idHumanInTheLoopMiddlewareon write toolsPIIMiddlewareon input and tool resultsSummarizationMiddlewareSegment 8 closes a loop opened in Segment 5. Watch for it.
3. HLD
Two planes. The control plane decides what is allowed. The execution plane does the work. Every fix below lands in one or the other. Knowing which is most of the skill.
flowchart TD IN["Inbound email"] --> TRIAGE["LCEL triage chain"] TRIAGE --> ROUTE{"Needs reasoning?"} ROUTE -- "no" --> FAST["Direct lookup, no loop"] ROUTE -- "yes" --> AGENT subgraph AGENT["create_agent loop"] direction TB BM["before model<br/>PII on input, then compaction"] --> LLM["Model call"] LLM --> AM["after model<br/>approval gate reads the proposed call"] AM --> GATE{"Needs a human?"} GATE -- yes --> PAUSE["interrupt, state saved"] GATE -- no --> WRAP PAUSE --> RESUME["resume with decision"] RESUME --> WRAP["wrap_tool_call layers<br/>guard, safety net, retry"] WRAP --> TOOLS["Tool executes"] TOOLS --> BM end AGENT --> OUT["Reply to desk agent"] CP[("InMemorySaver<br/>thread_id = AWB")] -.-> AGENT AGENT -.-> CP TOOLS --> EXT["Carrier API, booking system,<br/>notification service"]Hold on to one line: the checkpointer is not a memory feature. It is what the approval gate stands on. Segment 8 collects on that.
4. Rules of engagement
# TODO-n16 TODOs. 5 reasoning checkpoints. Answers are in a separate file. Open it after, not during.
When you are unsure of a parameter name, do not guess and do not search. Introspect:
That habit is worth more than any of the sixteen answers.
Preflight
Anything marked MISSING has a substitute noted in the segment that uses it.
Run steps
VS Code: create and activate a venv,
pip install -U langchain langchain-aws langgraph, set credentials withaws configureor the three AWS env vars, select the venv as your kernel.Colab:
!pip install -q -U langchain langchain-aws langgraph, set the three AWS env vars withos.environ, run top to bottom.5. Shared setup
Paste once. Everything below builds on it.
If
langchain.messagesdoes not import on your build, uselangchain_core.messages. Same class.Segment 0: Route before you reason
The idea
An LCEL chain is a fixed pipeline. Same shape every call, no tool access, no loop, cheap, unit testable. That makes it the right thing to put in front of an agent, not inside one.
Decide whether a case needs reasoning before you pay for reasoning.
flowchart LR E["Raw email"] --> P["ChatPromptTemplate"] --> M["Model"] --> J["JsonOutputParser"] J --> D{"case_type"} D -- "status_query + AWB" --> F["Direct read<br/>no loop, no tools"] D -- "disruption or claim" --> A["Agent"] D -- "other" --> H["Human queue"]Build it
TRIAGE_PROMPT | llm | JsonOutputParser()llm | TRIAGE_PROMPT | JsonOutputParser()JsonOutputParser() | TRIAGE_PROMPT | llmTRIAGE_PROMPT | JsonOutputParser() | llmProve it
Glue. The second call routed to
agent. You do not have one yet.Segment 1: Tools that tell the truth
The idea
create_agentdoes not catch tool exceptions for you. The default handler returns the message forToolInvocationErrorand re-raises everything else. An unhandledKeyErrorin your lookup ends the turn.So the choice of what to raise and what to return is architecture, not style. Three cases, three answers.
flowchart TD S["Something went wrong in a tool"] --> Q1{"Normal business outcome?"} Q1 -- "yes: no such AWB,<br/>no capacity, already rebooked" --> R1["RETURN a string<br/>the model can act on"] Q1 -- no --> Q2{"Would the same call<br/>succeed in 2 seconds?"} Q2 -- "yes: timeout, 5xx, throttle" --> R2["RAISE a typed exception<br/>the retry layer knows"] Q2 -- "no: bug, bad config, auth" --> R3["RAISE<br/>the safety net converts it"] R1 --> N1["Model replans"] R2 --> N2["Retry re-runs the call"] R3 --> N3["Model reports and stops"]Both wrong directions cost you. Returning a timeout as text hides it from retry, so nothing retries. Raising "no such AWB" ends a case over a typo.
Build it
raise ValueError(f"No shipment for {awb}")return f"NOT_FOUND: no shipment matches AWB {awb}. Ask the user to re-check the number."return Noneraise CarrierUnavailable("lookup failed")return "ERROR: carrier API timed out, try again later."raise CarrierTimeout(f"carrier API no response in 5s for {flight_no}")return f"ON_TIME: {flight_no}, assumed."time.sleep(5)return f"ALREADY_REBOOKED: {awb} is already on {_REBOOKED[awb]}. No change made."raise RuntimeError("duplicate rebooking attempt")pass_REBOOKED.pop(awb)TODO-4 hint: Segment 2 adds retries. Retries re-run calls. Decide what a re-run does to a booking before you pick.
Prove it
Glue. Lookups now survive a typo. Run
get_flight_statustwice and it raises both times. Nothing is catching that yet.Segment 2: Retry only what time can fix
get_flight_statusraisesCarrierTimeouton the first two callsThe idea
Retry is scoped, typed and bounded, or it is a liability. Retrying everything re-runs your own bugs three times with backoff. Retrying a write tool double-books freight.
flowchart TD C["Tool call"] --> W["ToolRetryMiddleware"] W --> T["Tool runs"] T -- success --> OK["ToolMessage to model"] T -- raises --> M{"Type listed in retry_on?"} M -- no --> PROP["Propagate now<br/>on_failure never runs"] M -- yes --> B{"Attempts left?"} B -- yes --> D["Wait initial_delay x backoff^n"] D --> T B -- no --> F{"on_failure"} F -- "continue" --> EMSG["ToolMessage with error text"] F -- "error" --> RAISE["Re-raise, run stops"]Build it
(Exception,)(CarrierTimeout, CarrierUnavailable)(ValueError, KeyError)NoneOn
tools=. Omit it and retry coversrebook_shipmenttoo. Combined with the wrong answer to TODO-4, that is how the same 480 kg gets booked three times. Scope retries the way you scope IAM.If preflight said
ToolRetryMiddlewareis MISSING, write it yourself. The handler may be called more than once, and each call is independent.Never use
yieldinside awrap_tool_callfunction. It becomes a generator and raisesNotImplementedError.Prove it
Hold this until Segment 4, when there is an agent to run it in. The check is a counter:
_status_calls["n"]reaches 3, and the model sees exactly one status result.Glue. You covered the two failures you named. Now put a bug in a tool the desk has used for a year and watch the turn end.
Segment 3: The safety net
The idea
Two moves in one hook. A wrap layer can catch, by wrapping
handlerintry. It can also short-circuit, by returning without callinghandlerat all.Catching turns a crash into a message. Short-circuiting stops a call from ever running. A guard beats a prompt instruction: a prompt asks the model not to do something, a guard makes it impossible.
flowchart TD MC["Model emits tool call"] --> G["Guard layer"] G -- "args invalid" --> SHORT["Return ToolMessage now<br/>handler never called"] G -- ok --> N["Safety net layer"] N --> R["Retry layer"] R --> TOOL["Tool"] TOOL -- "unexpected exception<br/>escapes retry" --> CATCH["Safety net catches"] CATCH --> CONV["ToolMessage, status=error,<br/>matching tool_call_id"] CONV --> MODEL["Model reads it and replans"]Build it
str(exc)ToolMessage(content=f"TOOL_FAILED: {request.tool_call['name']} raised {type(exc).__name__}. Report this to the user, do not retry.", tool_call_id=request.tool_call["id"], status="error")ToolMessage(content=str(exc), tool_call_id="0")raise exchandler(request)ToolMessage(content="BLOCKED: rebook_shipment requires an awb argument.", tool_call_id=request.tool_call["id"], status="error")Noneraise ValueError("missing awb")Two traps live in TODO-6. The
tool_call_idhas to match the call the model made, or you leave an unanswered tool call in history and the next model call fails on a different error than the one you were fixing. And the content should name the exception type, notstr(exc), because raw exception text carries hostnames, connection strings and paths you do not want in a prompt or a trace.Prove it
Run it both ways. Seeing the traceback once is the point of the segment.
Glue. Nothing crashes now. So the agent runs the full case, calls
rebook_shipment, and moves 480 kg of pharma. It also forgets the entire case the moment the turn ends.Segment 4: Assemble, and give it a memory
The idea
One case equals one thread.
thread_idis the memory key. Make it the AWB and the technical memory boundary becomes the business boundary.Two cases sharing a
thread_idmeans shipment A's context steers shipment B's reasoning. That bug survives QA and surfaces in front of a customer.flowchart LR U["Desk agent message"] --> ST["State for thread_id"] ST --> AG["create_agent loop"] AG --> TL["Tools"] TL --> AG AG --> RESP["Response"] AG -.-> CP[("InMemorySaver")] CP -.-> AG RESP --> ST2["State written back<br/>same thread_id"]Build it
memory=checkpointercheckpointer=checkpointersaver=checkpointerpersistence=checkpointer"thread_id": CASE_AWB"session_id": CASE_AWB"conversation_id": CASE_AWB"id": CASE_AWBProve it
That last print is the Segment 2 proof arriving.
Glue. Read
r1closely. Somewhere in there,rebook_shipmentexecuted. Nobody approved it.Segment 5: The approval gate
The idea
The gate runs after the model responds and before the tools run. That timing is the design. The model has decided what it wants to do, and nothing has happened yet. It is the only moment where approve, edit and reject are all still meaningful.
A gate after the side effect is not a control. It is a notification.
sequenceDiagram participant D as Desk agent participant A as Agent loop participant H as HITL middleware participant C as Checkpointer participant T as rebook_shipment D->>A: "MA-217 cancelled, sort out 160-45872910" A->>A: find_shipment, get_flight_status, find_alternate_flights A->>H: proposes rebook_shipment MA-219 H->>C: write paused state for thread_id H-->>D: interrupt with action_requests Note over D: reads pieces, weight, cold chain, then decides D->>A: Command resume, decision approve A->>C: read paused state back A->>T: execute with original args T-->>A: REBOOKED, ref RB-xxxxx A-->>D: confirmation grounded in the tool resultDelete the two checkpointer arrows and the sequence does not work. There is nowhere to pause. Remember that when you reach Segment 8.
Build it
{"find_shipment": True, "get_flight_status": True, "find_alternate_flights": True, "rebook_shipment": True, "notify_customer": True}{"rebook_shipment": {"allowed_decisions": ["approve", "edit", "reject"]}, "notify_customer": {"allowed_decisions": ["approve", "reject"]}, "find_shipment": False, "get_flight_status": False, "find_alternate_flights": False}{"rebook_shipment": True}{"find_shipment": False, "get_flight_status": False}result.get("interrupted")"__interrupt__" in resultresult["messages"][-1].tool_callsresult.status == "paused"{"decisions": [{"type": "approve"}]}[{"type": "accept"}]{"approve": True}"approve"The other two decisions:
One decision per pending action, in the same order the actions appear in the interrupt. Two pending calls and one decision is an error, not a partial approval.
rejectdenies an action.respondreturns the human's text as a successful tool result, so it is only for tools that exist to ask a human something. Denying a rebooking withrespondtells the model the rebooking worked.Prove it
Then run the case again with
{"type": "reject", "message": "PT-881 has no cold chain."}and confirm the agent replans instead of re-proposing the same flight.Glue. Look at what the approval screen just printed, and at the tool results behind it. The shipper's email and phone number are in both.
Segment 6: Three doors, not one
The idea
Identifiers enter at three doors: user input, model output, tool results. In this design the shipper contact arrives only through
find_shipment. Guard the wrong door and you get a clean compliance answer that is false.Diagram P1
flowchart LR U["User text"] --> R1["PII redact"] --> M["Model"] T["Tool result<br/>carries email + phone"] --> M M --> O["Output"]Diagram P2
flowchart LR U["User text"] --> R1["PII redact"] --> M["Model"] T["Tool result<br/>carries email + phone"] --> R2["PII redact"] --> M M --> R3["PII redact"] --> O["Output"]Build it
apply_to_output=Falseapply_to_tool_results=Trueapply_to_state=Truestrict=TruePIIMiddleware("phone", strategy="mask", apply_to_input=True)PIIMiddleware("phone_number", detector=r"\+\d{1,3}[\s.-]?\d{4,5}[\s.-]?\d{4,6}", strategy="mask", apply_to_input=True, apply_to_tool_results=True)PIIMiddleware("mobile", strategy="block")PIIMiddleware("contact", strategy="hash", apply_to_output=True)Before you pick TODO-14, run your candidate detector against these six strings. Five of them are not phone numbers, and this is a domain built out of numbers:
redact[REDACTED_EMAIL]mask****1223hashblockRedaction has a cost here.
notify_customerneeds a working address. Bonus 5 solves that properly instead of quietly switching redaction off for that path.Prove it
Both lines must lose the email and phone. Both must keep
160-45872910intact. If the AWB is masked, your detector is eating your business identifiers, and you have made the agent unable to name the shipment it is working on.Glue. The case is now clean and correct. It has also been open for four hours across a shift handover, and turn 38 just failed on context length.
Segment 7: Keeping a long case affordable
The idea
Compaction splits history, summarizes the old part, keeps the recent part, and hands both to the model.
flowchart TD H["History grows"] --> CH{"trigger met?"} CH -- no --> PASS["Pass through untouched"] CH -- yes --> SPLIT["Split history"] SPLIT --> OLD["Older messages"] SPLIT --> NEW["Most recent, per keep"] OLD --> SUM["Summarize"] SUM --> J["Summary + kept messages"] NEW --> J J --> MODEL["Model call"]Build it
max_tokens_before_summary=4000trigger=("messages", 12)trigger=12summarize_after=12One of these is a deprecated name that still runs. Introspect the signature before you choose, as instructed in Section 4. That is the whole point of the habit.
The failure to remember: the default
triggerisNone, andNonemeans summarization never fires. ASummarizationMiddlewarewith no trigger is a no-op that looks like a control.What this costs you here. Compaction turns older tool results into prose. The AWB, the cold chain flag and any completed write are facts later turns depend on. If the summary drops the cold chain flag, turn 22 proposes PT-881. Bonus 4 fixes that.
Prove it
Then run 15 short turns on one
thread_idand check the message count stops growing linearly.Glue. Everything works. It is 09:00, and the checkpointer backend just failed.
Segment 8: When memory fails
The idea
Go back to the Segment 5 sequence diagram. The gate pauses by writing state to the checkpointer. No checkpointer, no pause. No pause, no gate.
That makes a persistence outage a controls failure, not a memory failure. And the obvious fallback removes your only guardrail while looking like responsible engineering.
flowchart TD I["Turn arrives"] --> TRY["invoke with checkpointer"] TRY -- ok --> DONE["Normal path"] TRY -- "persistence error" --> Q{"Can we still pause<br/>for approval?"} Q -- no --> FC["FAIL CLOSED<br/>read only tools, no checkpointer,<br/>say writes are unavailable"] Q -- "yes, backup store healthy" --> FO["Switch checkpointer, retry once"] FC --> ESC["Route the write to the manual desk queue"] FO --> DONEBuild it
agent.invoke(payload, config=config)create_agent(model=llm, tools=TOOLS, system_prompt=SYSTEM_PROMPT).invoke(payload)degraded_agent.invoke(payload){"messages": [{"role": "assistant", "content": "Something went wrong."}]}Say your reasoning out loud before you check. One option keeps the agent fully useful and quietly removes the approval gate. It is the one that gets written during an incident by someone trying to restore service.
Prove it
Point
run_turnatbroken, send the case, and confirm two things: the failure is caught, and the reply offers a plan without offering to rebook anything.InMemorySavernever fails, which is why this failure is invisible in every demo. In production the checkpointer is Postgres or MongoDB, with outages, pool exhaustion and failovers. Write the degraded path now, because you will not be writing it calmly.Target output
All 16 filled in, driven by a scripted model so the tool path is deterministic:
Four things to read off it, one per link in the chain:
status tool calls : 3with one status result in the listrebook_shipmentand nothing elseAny of the four wrong tells you exactly which segment to reopen.
Reasoning checkpoints
R1: order the cards
middleware=[compaction, pii_email, approval_gate, tool_safety_net, transient_retry]One turn, model calls
rebook_shipment. Put these in execution order.Rules: before-model hooks run first to last down the list, after-model hooks run last to first, wrap hooks nest with the first entry outermost.
Second question, and it is the real one: given that order, is this list correct? State what is wrong with it and what you would swap.
R2: trace a flow
get_flight_statusraisesCarrierTimeouton attempts 1 and 2, succeeds on 3. Retry ismax_retries=2, on_failure="continue".R3: spot the wrong arrow
flowchart TD M["Model proposes rebook_shipment"] --> T["rebook_shipment executes"] T --> H["HITL middleware asks for approval"] H --> C[("Checkpointer writes paused state")] C --> D["Desk agent decides"] D --> R["Resume"] R --> OUT["Reply to desk agent"]One arrow makes the design pointless. Name it, and give the one-word fix.
R4: pick the correct diagram
P1 or P2 from Segment 6, and name the single argument doing the work.
R5: match the symptom to the missing control
KeyErrortraceback shown to a desk agent, case abandonedPIIMiddlewarewithapply_to_tool_results=TrueSummarizationMiddlewarewith a realtriggerwrap_tool_callsafety netToolCallLimitMiddlewareTwo letters are distractors.
Where this design fails
Honest list. None of it is fixed by the code above.
notify_customerneeds a real address. Either you disable redaction on that path or you pass a reference and resolve it outside the model. The second is correct and more work.InMemorySaverhides the interesting failureTest inputs
MA-217 on 6 Aug cancelled, AWB 160-45872910, pharma cold chain. Advise.rebook_shipmentStatus of AWB 160-44120087?AWB 160-99999999, what is happening?NOT_FOUNDas data, agent asks for a correction, run survivesALREADY_REBOOKEDwhat did we just do?on the same threadthread_idJust rebook it onto whatever is cheapest, do not ask me.Send an update to ops@kavery-exports.example and +91 98450 11223PT-881 has no cold chainBonus
whenpredicate so only shipments above 250 kg or flagged cold chain interrupt. Then defend that threshold in three lines. Check your build supportswhenfirst.ToolCallLimitMiddleware(tool_name="find_alternate_flights", run_limit=2)and try all threeexit_behaviorvalues. Write down what the model does differently under each.@before_modelhook that appends one structured line per turn: thread id, message count, last tool called. This is what you hand an auditor.SummarizationMiddlewareasummary_promptthat must preserve AWB, commodity constraints and any completed write verbatim. Run 25 turns and check the cold chain flag survives.notify_customerto takeawbinstead ofcontactand resolve the address inside the tool. Full redaction now costs nothing. Note what else this fixes.@wrap_tool_calllayers, one print each, one tool call. Read the order off your own console and write the answer at the top of your file.Close the chain
Fill this in from memory before you open the key.
All reactions