cd /news/ai-agents/agentic-langgraph-from-first-princip… · home topics ai-agents article
[ARTICLE · art-124099] src=pub.towardsai.net ↗ pub= topic=ai-agents verified=true sentiment=· neutral

Agentic LangGraph — From First Principles to a Working Multi-Agent System

A new learning repository, Agentic-Langgraph-custom, extends Krish Naik's Agentic LangGraph Crash Course with a custom multi-agent module, demonstrating a two-agent research-to-report pipeline using openai/gpt-oss-20b served by Groq, Tavily for search, and LangSmith for observability. The repository includes detailed troubleshooting guidance, such as handling rate-limit pauses and correctly configuring the LangSmith endpoint for AWS data regions, based on real runs.

by read21 min views1 publishedSep 9, 2026

A learning repository that goes all the way from “what is a node?” to a two-agent research → report pipeline, fully traced in LangSmith and debuggable in LangGraph Studio.

Every code snippet here is taken from the modules in this repo, and every number, error message and screenshot comes from a real run on this machine — not from documentation.

8. Troubleshooting — every error we actually hit

A fork of Krish Naik’s Agentic LangGraph Crash Course, extended with a custom multi-agent module and a lot of hard-won operational detail.

Model used throughout: openai/gpt-oss-20b served by Groq.

Search: Tavily.

Observability: LangSmith (AWS data region).

git clone <your-repo-url>cd Agentic-Langgraph-custom# 1. install (uv is the source of truth; uv.lock is committed)uv sync# 2. credentialscp .env.example .env#    then edit .env — see section 3# 3. run the multi-agent pipelinecd 5-Multitoolspython agent.py

Expected: 2–3 minutes of wall clock, a printed flow, and a markdown report in 5-Multitools/reports/. If it looks stuck need to read section 8.4 — it is almost certainly rate-limit s, not a hang.

To explore visually instead:

cd 5-Multitools && langgraph dev      # opens LangGraph Studio

This is the part that silently breaks everything, so it comes before the theory.

Settings → API Keys. A key is shown once at creation — if we lose it, create a new one. Note the Personal / Service toggle: personal tokens are scoped to only us, service keys to a workspace.

.env is gitignored. .env.example is committed as the template. Never commit .env — verify with git check-ignore -v .env before your first push.

Three consumers, one file:

{    "dependencies": ["."],    "graphs": { "multi_agent": "./agent.py:multi_agent" },    "env": "../.env"}

Then the code promotes and validates them:

load_dotenv()def _require(name: str) -> str:    value = os.getenv(name)    if not value:        raise RuntimeError(f"{name} is missing. Add it to the .env at the repo root.")    return valueos.environ["GROQ_API_KEY"] = _require("GROQ_API_KEY")_require("TAVILY_API_KEY")_langsmith_key = os.getenv("LANGSMITH_API_KEY") or os.getenv("LANGCHAIN_API_KEY")if _langsmith_key:    os.environ["LANGSMITH_API_KEY"] = _langsmith_key    os.environ["LANGSMITH_TRACING"] = "true"    os.environ["LANGSMITH_PROJECT"] = "multiagent-summarizer"    os.environ.setdefault("LANGSMITH_ENDPOINT", "https://aws.api.smith.langchain.com")

Why _require instead of the common os.environ["X"] = os.getenv("X")?

Because if the variable is missing, that idiom raises TypeError: str expected, not NoneType from deep inside os.environ, which tells you nothing. _require names the variable and the file.

Why LANGSMITH_API_KEY or LANGCHAIN_API_KEY?

The SDK renamed its variables; both are still honoured, LANGSMITH_* wins. This repo stores the key under the older name and promotes it, so either works.

Why setdefault for the endpoint but = for the project?

setdefault lets .env override the region (portable for other users), while = guarantees this module's traces never leak into another module's project.

LangSmith runs three separate deployments. A key issued in one is rejected by the others with an error that looks exactly like an invalid key.

LangChain Support confirming the account is on the AWS region and must set LANGSMITH_ENDPOINT.

Symptom if you get this wrong:

Failed to multipart ingest runs: langsmith.utils.LangSmithError:Failed to POST https://api.smith.langchain.com/runs/multipart in LangSmith API.HTTPError('403 Client Error: Forbidden ...', '{"error":"Forbidden"}')

Critical gotcha: LangSmith returns 403 for unknown keys too — not 401. So a 403 tells you "auth failed" and nothing more. It does not mean "the key is recognised but restricted". Do not conclude your key was revoked.

Find your region in one command:

k=$(grep '^LANGCHAIN_API_KEY' .env | cut -d= -f2- | tr -d '"'"'"' \r')for h in api eu.api aws.api; do  printf "%-8s -> " "$h"  curl -s -o /dev/null -w "%{http_code}\n" -H "x-api-key: $k" \    "https://$h.smith.langchain.com/api/v1/sessions?limit=1"done

200 on exactly one host is your endpoint. The tr -d also strips the trailing \r that Windows CRLF line endings leave in the value — leave it in and the header itself is malformed.

After editing .env, restart the kernel. Two reasons:

So re-running the load_dotenv() cell changes nothing, and you keep hitting the same 403 with a correct .env sitting on disk.

LangChain gives us a chain:

a fixed pipeline, A → B → C.

LangGraph gives us a state machine:

nodes that read and write they all shared a state, and edges — including conditional ones — that decide where to go next. That is what makes loops possible, and a loop is what turns a chatbot into an agent.

Three concepts and nothing else:

        ┌─────────── STATE ───────────┐   a dict every node reads and writes        │                             │   ── NODE ──► NODE ──► NODE ──►      │   functions that return partial updates        │        ▲        │           │        └── EDGE ─┘   EDGE ┘          │   wires, some conditional

State is a TypedDict. Each key is a channel. A node returns a partial update; LangGraph merges it in.

from typing_extensions import Annotated, NotRequired, TypedDictfrom langchain_core.messages import BaseMessagefrom langgraph.graph.message import add_messagesclass ReportState(TypedDict):    messages: Annotated[list[BaseMessage], add_messages]   # reducer: append    research_notes: NotRequired[str]                       # optional, overwrite    report: NotRequired[str]

Reducers — the key idea

Without a reducer, a returned value replaces the channel. With a reducer, it is merged. add_messages is the reducer you will use most. Verified behaviour:

That last row is how weedit history rather than grow it.

NotRequired marks a key optional, which keeps Studio's input form clean: messages shows as required, the rest are blank until a node fills them.

A node is any callable state -> partial update.

def research_agent(state: ReportState):    reply = research_llm.invoke(        [SystemMessage(content=RESEARCH_PROMPT), *state["messages"]]    )    update = {"messages": [reply]}                 # merged by add_messages    if not getattr(reply, "tool_calls", None):        update["research_notes"] = reply.content   # plain overwrite    return update

Three things worth copying from this:

from langgraph.graph import START, END, StateGraph
builder = StateGraph(ReportState)builder.add_node("research_agent", research_agent)builder.add_node("research_tools", ToolNode(research_tools))
builder.add_edge(START, "research_agent")        # entry pointbuilder.add_edge("research_tools", "research_agent")   # unconditional

A router function inspects state and returns the name of the next node.

builder.add_conditional_edges(    "research_agent",    tools_condition,                                    # the router    {"tools": "research_tools", END: "report_agent"},   # verdict -> destination)

tools_condition is LangGraph's prebuilt router. Verified signature:

def tools_condition(state, messages_key="messages") -> Literal["tools", "__end__"]: ...

It returns "tools" if the last AIMessage has tool calls, else "end".

The third argument is a path map, and it is the trick that makes multi-agent work. tools_condition only ever says "tools" or END, but you get to decide what those words mean for each node. Here END from research_agent is remapped to report_agent — so "agent 1 has stopped calling tools" becomes "hand off to agent 2" instead of "finish". One prebuilt router, two different meanings.

graph = builder.compile()                 # validates the graph, returns a Runnablestate = graph.invoke({"messages": "Hi"})  # run to completion

compile() is where LangGraph checks that every edge points somewhere real. If you edit a node function afterwards, see the staleness warning.

Visualise it:

from IPython.display import Image, displaydisplay(Image(graph.get_graph().draw_mermaid_png()))

A tool is a Python function the model may call. @tool turns the signature and docstring into a JSON schema, so the docstring is a prompt — write it for the model.

from langchain_core.tools import tool
php
@tooldef multiply(a: int, b: int) -> int:    """Multiply a and b.
Args:        a (int): first int        b (int): second int
Returns:        int: output int    """    return a * b

Bind them to the model and put them in a ToolNode:

llm_with_tools = llm.bind_tools([multiply, addition, get_weather, search])builder.add_node("tools", ToolNode([multiply, addition, get_weather, search]))

bind_tools tells the model what it may call. ToolNode is what actually executes the call and appends a ToolMessage. You need both.

Binding TavilySearch directly returns full page content per hit — measured at 8,130 tokens for one call, which alone exceeded the token budget. Wrapping it fixes that and costs nothing:

@tooldef web_search(query: str) -> str:    """Search the web for current information on a topic.
Returns a compact list of results: title, URL and a short snippet each.    """    raw = TavilySearch(max_results=4).invoke({"query": query})    blocks = []    for hit in raw.get("results", []):        snippet = " ".join((hit.get("content") or "").split())[:280]        blocks.append(f"- {hit.get('title')}\n  url: {hit.get('url')}\n  {snippet}")    return "\n".join(blocks) or f"No results for: {query}"

In the trace this shows as tavily_search nested inside web_search — proof the wrapper is in the path.

Sometimes a tool needs data the model shouldn’t have to retype. Make the model pass the report text to three tools and it crosses the wire four times per run.

from langgraph.prebuilt import InjectedState
php
@tooldef report_stats(state: Annotated[dict, InjectedState]) -> dict:    """Measure the report already drafted by make_markdown_report. Takes no arguments."""    text = _latest_draft(state["messages"])    words = len(text.split())    return {"words": words, "characters": len(text),            "bullets": text.count("\n- "),            "reading_time_minutes": max(1, round(words / 200))}

InjectedState is filled in by LangGraph at execution time and is absent from the schema the model sees. In the trace you will see report_stats called with no arguments. This one change removed roughly two-thirds of the token cost of agent 2.

Reason + Act: the model calls a tool, reads the result, and decides again. In LangGraph that is a single edge pointing backwards.

builder.add_conditional_edges("agent", tools_condition)builder.add_edge("tools", "agent")      # ← the loop

Contrast:

WiringBehaviourbuilder.add_edge("tools", END)one tool call, then stop

builder.add_edge("tools", "agent")model reads the result and may call again

Why it matters, from a real run of “What is 12 multiplied by 4, then add 8?”:

AI   -> multiply(a=12, b=4)TOOL -> 48AI   -> addition(a=48, b=8)      ← 48 came from the previous tool resultTOOL -> 56AI   -> "The result is 56."

multiply → 48, then addition(a=48, b=8) → 56. Without the back-edge the run stops after multiply and the second step never happens.

Models like to shortcut trivial work. A system prompt fixes it:

SYSTEM_PROMPT = (    "For every distinct action requested (weather, search, math, etc.), you must "    "call the corresponding tool - never compute or answer any part yourself, "    "even if it seems trivial.")

With this in place, a three-part request routed all three parts through tools:

“Give me the weather of dhaka and then give me recent sports news finally add 5 with 4” → get_weather, tavily_search, and addition — the model called a tool even for 5 + 4.

invoke() returns the final state. stream() yields after each node, which is how you watch an agent work.

for event in graph.stream({"messages": "Write a brief report on EV battery recycling"}):    for node, value in event.items():        last = value["messages"][-1]        calls = [c["name"] for c in getattr(last, "tool_calls", None) or []]        print(f"{node:<16} tools={calls or '-'}")
research_agent   tools=['web_search']research_tools   tools=-research_agent   tools=['web_search']research_tools   tools=-research_agent   tools=-            ← handoffreport_agent     tools=['make_markdown_report']

By default a graph is stateless — every invoke() starts empty:

graph.invoke({"messages": "Hello my name is faizul"})graph.invoke({"messages": "What is my name"})     # no idea

Add a checkpointer and a thread:

from langgraph.checkpoint.memory import MemorySaver
memory_graph = builder.compile(checkpointer=MemorySaver())config = {"configurable": {"thread_id": "1"}}
r1 = memory_graph.invoke({"messages": "Write a short report on Bangladesh IT outsourcing"}, config=config)r2 = memory_graph.invoke({"messages": "Now add a section on the main risks"}, config=config)

r2's prompt never names the topic, and it still works — the checkpointer replayed thread "1".

For persistence across restarts use SqliteSaver or PostgresSaver instead. Under langgraph dev, leave the checkpointer out — the platform supplies its own, which is why the factory takes it as an argument:

def make_multi_agent_graph(checkpointer=None):    ...    return builder.compile(checkpointer=checkpointer)
multi_agent = make_multi_agent_graph()          # Studio: no checkpointer

Three things about memory that will bite you

  1. Token cost compounds. Replaying the thread means every turn is bigger. Measured across one run:

Eventually a single call exceeds your per-minute budget and you get 413 Request too large, which no retry can fix. Use a fresh thread_id per topic:

config = {"configurable": {"thread_id": "2"}}   # new topic, clean history
  1. Follow-ups inherit the original framing. Asking “Now add a section on the AI risks” produced a document still titled “Short Note on AI Definition” — agent 2 re-drafts the whole thread and titles it after turn one.

  2. Each result is an independent snapshot. response, r1 and r2 are separate dicts. Printing response['report'] after a memory run shows the old document. Nothing updates a variable you assigned earlier.

LangSmith is the difference between “it broke” and “it broke on the fourth LLM call inside agent 2, here are the exact arguments”. Everything in this section was used to debug the real failures in section 8.

Three environment variables, and tracing is automatic — no code changes, no wrappers:

os.environ["LANGSMITH_TRACING"] = "true"                  # the master switchos.environ["LANGSMITH_API_KEY"] = "lsv2_pt_..."os.environ["LANGSMITH_PROJECT"] = "multiagent-summarizer" # where traces landos.environ["LANGSMITH_ENDPOINT"] = "https://aws.api.smith.langchain.com"

Every langchain / langgraph call is instrumented, so the graph, each node, each LLM call and each tool call are all reported.

Set LANGSMITH_TRACING=false to turn it off. Tracing failures are non-fatal — ingest runs on a background thread, so a 403 spams stderr but your graph still returns a correct answer. You lose the trace, not the result.

os.environ["LANGSMITH_PROJECT"] = os.getenv(    "LANGSMITH_PROJECT_MULTIAGENT", "multiagent-summarizer")

Plain = rather than setdefault so a stray global can't merge these traces into another module's project. This repo uses TestProject for 3-Debugging and multiagent-summarizer for 5-Multitools.

Per-project rollups: trace count, error rate, P50/P99 latency, tokens, cost.

The name of the root run tells you the entry point:

Same graph, different launcher. Do not read anything else into it.

New accounts land in an application scope that hides workspace projects:

“No tracing projects in ‘My First App’ — 1 tracing project exists in your workspace, but it’s hidden because you’re viewing ‘My First App’.”

Click All applications. Your project was there the whole time.

tool_agent                          1.23s   1.2K tok   $0.0002├─ tool_calling_llm                 0.56s│  ├─ ChatGroq  openai/gpt-oss-20b  0.55s     579 tok│  └─ tools_condition               0.00s├─ tools                            0.01s│  └─ multiply                      0.00s└─ tool_calling_llm                 0.54s   └─ ChatGroq                      0.53s     592 tok

How to read it:

The two-agent pipeline: research_agent → tools_condition → research_tools → web_search → tavily_search, then the handoff to report_agent. The Attributes → Metadata panel also shows the LANGSMITH_ENDPOINT and LANGSMITH_PROJECT actually in effect — useful when you are unsure which config the process picked up.

The single most useful debugging technique in this repo. A malformed tool call was crashing runs; a retry was added; runs still crashed. Was the retry running?

Count the LLM spans under the failing node.

The real trace:

10:39:40  report_agent   error   TOOL_USE_FAILED10:39:40  ChatGroq       error   TOOL_USE_FAILED    ← ONE span

One span proved the kernel was running pre-patch code — the fix was correct, the process was stale. Without this the obvious (and wrong) conclusion is “the retry doesn’t work”.

Counter-example: token count is not a retry signal. A failing run showed 2,861 tokens, which looks like several attempts. It was three successful research calls; the failed call billed 0 tokens. Count spans, not tokens.

The UI is for browsing; the API is for answering questions. These cost no LLM tokens — invaluable when you are rate-limited.

k=$(grep '^LANGCHAIN_API_KEY' .env | cut -d= -f2- | tr -d '"'"'"' \r')BASE=https://aws.api.smith.langchain.com

Does my project exist and when did it last run?

curl -s -H "x-api-key: $k" "$BASE/api/v1/sessions?name=multiagent-summarizer"

What failed, and why?

sid=$(curl -s -H "x-api-key: $k" "$BASE/api/v1/sessions?name=multiagent-summarizer" \      | python -c "import sys,json;print(json.load(sys.stdin)[0]['id'])")
curl -s -X POST "$BASE/api/v1/runs/query" -H "x-api-key: $k" \  -H "Content-Type: application/json" \  -d "{\"session\":[\"$sid\"],\"is_root\":true,\"limit\":20,       \"select\":[\"name\",\"status\",\"error\",\"start_time\",\"end_time\",\"total_tokens\"]}"

Real output, summarised:

10:55:04 -> 10:57:36   success  tok=2742510:53:16 -> 10:53:45   success  tok=1109310:52:24 -> 10:52:25   error    tok=0

Drop "is_root": true to get every span, which is how you count LLM calls per node (5.5).

Is my key valid, and for which region? See 3.4.

Monitoring → Cost & Tokens: totals and per-trace tokens over time.

Measured on this repo:

Latency profile for the single-agent tools:

A visual debugger for our graph: run it, watch nodes light up, inspect state at each step.

langgraph.json next to our agent.py:

{    "dependencies": ["."],    "graphs": { "multi_agent": "./agent.py:multi_agent" },    "env": "../.env"}

The graphs value is module:variable — the variable must be a compiled graph at import time, which is why agent.py ends with multi_agent = make_multi_agent_graph().

cd 5-Multitoolslanggraph dev            # or ../.venv/Scripts/langgraph.exe dev

Healthy startup:

Importing graph profiling  graph_id=multi_agent  path=./agent.pyApplication started up in 3.363sHTTP Request: POST https://aws.api.smith.langchain.com/v1/metadata/submit "204 No Content"

That last line confirms Studio picked up your region from .env via langgraph.json

Graph on the left, thread on the right. Type into messages and hit Submit.

What to look for:

Note that Studio talks to the same LangSmith project, so everything we do appears in your traces too — under the root-run name multi_agent rather than LangGraph.

langgraph dev runs in-memory. The bundled langgraph-verify-graphs tool targets the production server and needs Redis:

KeyError: "Config 'REDIS_URI' is missing, and has no default."

That is expected, not a broken install. Verify a graph loads by importing it instead:

python -c "from agent import multi_agent; print(list(multi_agent.get_graph().nodes))"

Everything above, assembled.

The user asks about a topic. A search agent gathers information with Tavily. That information is handed to a summarizer/report agent, which has its own tools for summarizing, formatting and saving a polished report.

                      ┌──────────────── shared `messages` channel ───────────────┐                      │                                                          │  START ──► research_agent ⇄ research_tools          report_agent ⇄ report_tools ──► END                      │        (web_search)               ▲          (make_markdown_report,                      │                                   │           report_stats,                      └───── handoff: research_notes ─────┘           save_report)
builder.add_edge(START, "research_agent")

Two mechanisms, deliberately:

Each agent stays in character because each node prepends its own system prompt to the same shared history.

State keyContainsSame as the .md file?research_notesagent 1's raw bullet notes + URLsnoreportthe make_markdown_report outputyes, identical text

update["report"] = _latest_draft(state["messages"]) or reply.content

report is deliberately not reply.content: agent 2's final message is a one-line sign-off, because the prompt forbids repeating the report (a token-saving measure). So the document is recovered from the tool output instead.

As a script:

cd 5-Multitoolspython agent.py                                             # default topicpython agent.py Write a short report on AIOps in Bangladesh # args are joined

From a notebook: 5-Multitools/multiagent.ipynb, 41 cells, built up in the same order as section 4.

In Studio: langgraph dev, then type into messages

TOPIC: Write a short report on electric vehicle battery recycling--- flow ---  agent -> calls web_search  tool  web_search: - ReCell In The News 2024 - ReCell Center  agent -> calls web_search  tool  web_search: - Future of EV Battery Recycling: Technologies and Challenge  agent -> text (3085 chars)                    ← agent 1 done, handoff  agent -> calls make_markdown_report  tool  make_markdown_report: # Electric Vehicle Battery Recycling: Current...  agent -> calls report_stats  tool  report_stats: {"words": 308, "characters": 2587, "bullets": 10, ...}  agent -> calls save_report  tool  save_report: Saved 2587 characters to reports/ev-battery-recycling-report.md  agent -> text (55 chars)                      ← one-line sign-off

Every design decision is visible here: two searches then a handoff, report_stats called with no arguments (InjectedState), save_report given only a filename, and a 55-character sign-off instead of a repeated report.

Produced document:

*Compiled 2026-09-03*
## Executive summary...
## Key findings- Market size & growth — valued at USD 25.49 billion in 2024...- Current commercial recovery rates: ~70-80 % cobalt, ~60-70 % nickel...
## Sources1. ReCell Center (2024) — https://recellcenter.org/newsroom/recell-in-the-news-20242. U.S. DOE Fact Sheet (2024) — https://www.energy.gov/...

Fail fast on config. _require() names the missing variable and the file.

Degrade, don’t crash. Tavily is only wired in when its key exists, so the graph still loads for someone without a Tavily account:

def build_research_tools() -> list:    tools = [...]    if os.getenv("TAVILY_API_KEY"):        tools.insert(0, ...)    return tools

Timeouts and readable failures on every HTTP tool — a raised exception inside a node kills the graph, a returned string lets the model recover:

try:    ...  # the HTTP callsexcept requests.RequestException as exc:    return f"Weather lookup failed for {city}: {exc}"

Constrain file writes. save_report takes a filename from a language model, so it strips anything path-like and forces the extension:

stem = re.sub(r"[^A-Za-z0-9._-]", "-", Path(filename).stem).strip("-.") or "report"target = REPORTS_DIR / f"{stem}.md"

Retry what is retryable, and only that — see 8.1 and 8.2.

Console encoding on Windows. Reports contain U+2011 (non-breaking hyphen), U+202F (narrow no-break space) and ≈. The console is cp1252, so without this the script dies with UnicodeEncodeError after spending all the tokens:

sys.stdout.reconfigure(encoding="utf-8", errors="replace")
BadRequestError: Error code: 400 - Failed to parse tool call arguments as JSON'failed_generation': '{"name": "web_search", "arguments": {"query":"Bangladesh overview 2024","}""}'

The model emitted invalid JSON for its own tool arguments. Observed corruption varies between runs — ,"" , ,"}"" — which is what proves it is random sampling damage rather than a schema bug. Confirmed intermittent: many runs succeed on the identical schema.

Fix: re-sample. Do not switch models.

RateLimitError: 429 - TPM: Limit 8000, Used 7841, Requested 4974.Please try again in 36.1125s

Groq’s free tier allows 8,000 tokens per minute across all calls. Groq tells us how long to wait, so honour it.

RETRY_ATTEMPTS = 4MAX_RATE_LIMIT_WAIT = 90.0
python
def invoke_with_retry(model, messages):    """Retry the two failures this stack actually produces."""    last_error = None    for attempt in range(1, RETRY_ATTEMPTS + 1):        try:            return model.invoke(messages)        except BadRequestError as exc:            if "tool_use_failed" not in str(exc):                raise                      # a real bug — surface it            print(f"[malformed tool call: re-sampling {attempt}/{RETRY_ATTEMPTS}]")            last_error = exc        except RateLimitError as exc:            match = re.search(r"try again in ([\d.]+)s", str(exc))            wait = min(float(match.group(1)) + 1.0, MAX_RATE_LIMIT_WAIT) if match else 20.0            print(f"[rate limited: waiting {wait:.0f}s, attempt {attempt}/{RETRY_ATTEMPTS}]")            time.sleep(wait)            last_error = exc    raise last_error

The if "tool_use_failed" not in str(exc): raise line matters. Without it this swallows genuine 400s — a bad model name, a malformed request — and retries them pointlessly before failing with a confusing error. Verified behaviour:

ScenarioResult2 malformed calls then successrecovers on attempt 3always malformed4 attempts, then raisesunrelated 400 (bad model name)re-raised immediately

It deliberately does not handle 413 Request too large — waiting cannot shrink an oversized request. If you see 413, cut tokens or start a new thread.

The most expensive hour in this repo. add_node captures the function object:

cell A   invoke_with_retry           ← helpercell B   research_agent / report_agent   ← closures calling itcell C   builder.add_node(...)       ← CAPTURES the function objectscell D   builder.compile(...)

Re-running B rebinds the name but leaves the original function inside builder. graph and memory_graph both inherit the stale node.

Restart the kernel and Run All, or re-run A → B → C. Then verify for free:

import inspectfor name in ("research_agent", "report_agent"):    fn = graph.nodes[name].node.steps[0].func    print(f"{name:<15} retry wired: {'invoke_with_retry' in inspect.getsource(fn)}")

The node is a RunnableSeq: steps[0] is your function, steps[1:] are LangGraph's channel-write and routing steps. .node.func raises AttributeError.

It is not a hang. Verified trace 10:55:04 → 10:57:36, success, 27,425 tokens:

LLM callTokensGap before next step13,599–24,29631s34,77539s44,81733s54,85641s

At ~4,800 tokens per call against 8,000 per minute you get roughly 1.6 calls per minute, and a run needs 6–8 calls. The arithmetic is the runtime.

[rate limited: waiting 37s, attempt 1/4] in the output is our liveness signal. No such lines and no progress for minutes is a real hang.

To cut cost, at the top of agent.py:

SEARCH_MAX_RESULTS = 3      # from 4SEARCH_SNIPPET_CHARS = 180  # from 280

plus RESEARCH_PROMPT: "Run at most 2 searches""Run exactly 1 search". Roughly 6–8k tokens per run, which fits one minute. Tradeoff: thinner research.

413 - Request too large ... TPM: Limit 8000, Requested 8130

A single request exceeded the budget. Binding TavilySearch directly caused this (full page content per hit). Wrap noisy tools (4.5) and use InjectedState so the model never re-emits large text.

Wrong data region. See 3.4. Remember 403 also means “unknown key” — it is not evidence your key is valid.

Windows cp1252 console meeting model output. Either sys.stdout.reconfigure(encoding="utf-8", errors="replace") in the script, or:

PYTHONIOENCODING=utf-8 python your_script.py

Notebooks are unaffected.

PowerShell syntax in Git Bash. Backslash is an escape character there:

../.venv/Scripts/langgraph.exe dev     # Git Bash
..\.venv\Scripts\langgraph.exe dev     # PowerShell
Agentic-Langgraph-custom/├── .env                  # your keys — GITIGNORED, never commit├── .env.example          # template, committed├── .gitignore├── README.md             # this file├── pyproject.toml        # uv-managed deps├── uv.lock│├── 1-BasicChatbot/chatbot.ipynb├── 2-HumanAssistance/humanintheloop.ipynb├── 3-Debugging/│   ├── agent.py          # tool_agent: search, weather, multiply, addition│   ├── debugging.ipynb│   └── langgraph.json├── 4-Multimodal/├── Agents/multiaiagent.ipynb│├── 5-Multitools/         # ← the multi-agent project│   ├── agent.py          # multi_agent graph + CLI runner│   ├── multiagent.ipynb  # 41-cell walkthrough│   ├── langgraph.json│   └── reports/          # generated .md files (gitignored)│└── docs/    ├── SCREENSHOT-NOTES.md    └── assets/langsmith-studio/    # 26 annotated screenshots
git check-ignore -v .env          # must report a matchgit ls-files | grep -i "\.env$"   # must return nothing

.gitignore covers .env* (except .env.example), .venv/, pycache/, .langgraph_api/ (which holds real conversation state), .pckl, and 5-Multitools/reports/.md.

Notebooks store outputs. They are clean today, but a cell that prints os.environ would bake a live key into a committed .ipynb. To enforce:

uv add --dev nbstripout && nbstripout --install

The screenshots in docs/assets/ contain a LangSmith org UUID and a partial API key prefix. Neither is exploitable, but make publishing them a deliberate choice.

Forked from Krish Naik’s Agentic LangGraph Crash Course and extended with the 5-Multitools multi-agent module, the LangSmith operational material, and the troubleshooting sections — all derived from real failures on this machine.

Git link: faizulkhan56/Agentic-Langgraph-custom

Agentic LangGraph — From First Principles to a Working Multi-Agent System was originally published in Towards AI on Medium, where people are continuing the conversation by highlighting and responding to this story.

── more in #ai-agents 4 stories · sorted by recency
── more on @krish naik 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/agentic-langgraph-fr…] indexed:0 read:21min 2026-09-09 ·