Skip to main content

TST, Hongkong

Hello LanGraph!

In the previous example I explored LangChain a little deeper. Now let's take a look at the deployment of agentic tool chains build in LangChain in form of graphs -> this is LangGraph. As the name already suggests this library is all about Nodes & Edges and Stateful Orchestration.

Environment Setup​

Start by creating your virtual environment using uv:

uv init
uv sync
git add .
git commit -m "Initial commit"
uv add langchain-core langchain-openai langchain-community langgraph

And make sure your terminal is using it:

which python
/usr/bin/python
source .venv/bin/activate
which python
~/Projects/rag-experiments/langgraph_intro/.venv/bin/python

Hello Graph​

Let's build a simple graph with a single node (model call):

from typing import TypedDict
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END

client = ChatOpenAI(
model="qwen3.8:latest",
temperature=0.6,
top_p=0.95,
base_url="http://localhost:11434/v1",
api_key="ollama",
)

# Define the schema

class graph_shema(TypedDict):
name: str
message: str

# Create Node functions

def welcome(state: graph_shema) -> graph_shema:
current_name = state['name']
current_message = state['message']

resonse = client.invoke(f"My name is {current_name}. {current_message}").content

state['message'] = f"You asked {current_message}. Here is the response: {resonse}."

return state

# Create the State Graph

## Adding Nodes
graph = StateGraph(graph_shema)
graph.add_node("welcome", welcome)
## Adding Edges
graph.add_edge(START, "welcome")
graph.add_edge("welcome", END)
graph = graph.compile()

# Run Graph

response = graph.invoke({"name": "Mike", "message": "Hey there!"}) # expects graph_shema!

print(response)
python -m langgraph_intro.langgraph_basics
{'name': 'Mike', 'message': "You asked Hey there!. Here is the response: Hey Mike! 👋 Good to meet you. How's your day going? What can I help you with today?."}

Nice, this is working! Next step, since we are using a fixed schema inside our node function we can control this using Pydantic.

Schema Enforcement with Pydantic​

Now +1 one the idea adding another node and enforce the node schema using pydantic:

from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END

client = ChatOpenAI(
model="qwen3.8:latest",
temperature=0.6,
top_p=0.95,
base_url="http://localhost:11434/v1",
api_key="ollama",
)

# Create structured Schema

class graph_shema(BaseModel):
topic: str = Field(description="The topic of the post")
post: str = Field(description="The social media post content")
processed_post: str = Field(description="The curated social media posts content")

demo = graph_shema(
topic="AI Coding Language Benchmarks",
post="",
processed_post=""
)

# Create Node functions

def generate_post(state: graph_shema) -> graph_shema:

topic = state.topic
# passing topic to model to generate post
post = client.invoke(f"Write a social media post about {topic}").content

# Update state with model response
state.post = str(post)

return state

def process_response(state: graph_shema) -> graph_shema:
post = state.post

processed_post = client.invoke(f"Re-write the following scial media post in a West-Coast Hip Hop tone: {post}").content

state.processed_post = str(processed_post)

return state

# Create the State Graph

## Adding Nodes
graph = StateGraph(graph_shema)
graph.add_node("generate_post", generate_post)
graph.add_node("process_response", process_response)
## Adding Edges
graph.add_edge(START, "generate_post")
graph.add_edge("generate_post", "process_response")
graph.add_edge("process_response", END)
graph = graph.compile()

# Run Graph

response = graph.invoke(demo) # expects graph_shema!

print(response)
python -m langgraph_intro.langgraph_schema_control
{'topic': 'AI Coding Language Benchmarks', 'post': '🧠 **AI Coding Benchmarks: The Scoreboard That Might Be Lying to You**\n\nSWE-bench went from 13% → 70%+ in under two years.\nHumanEval? Basically solved.\nLiveCodeBench? Still climbing.\n\nAnd yet… half the devs I talk to say their "top-ranked" AI assistant still can\'t handle a mid-sized refactoring task without hallucinating a dependency.\n\nHere\'s the tension I keep circling back to:\n\n📊 Benchmarks measure *narrow, well-scoped, single-file* problem solving.\n🏗️ Real codebases are messy, multi-file, context-heavy, and full of "why did *this* person name the variable `tmp2_final_v3`?"\n\nSo what are we actually benchmarking?\n- Pattern recognition? (Solved.)\n- Algorithmic reasoning? (Getting there.)\n- *Engineering judgment*? (Not even close.)\n\nA few things I\'d love to see in the next generation of evals:\n\n→ Multi-file, multi-step tasks with ambiguous requirements\n→ "Fix the bug" in a 50k-line repo with no test suite\n→ Maintaining consistency with an existing (bad) code style\n→ Knowing when to say "I don\'t know" vs. confidently shipping garbage\n\nThe benchmark arms race is fun. But a model that scores 95% on HumanEval and still breaks your CI pipeline isn\'t the model you need. It\'s the model you *market* to.\n\nSo here\'s my question to the thread: **When was the last time an AI coding tool genuinely impressed you in a real project — and what did it actually do well?**\n\nDrop it below. I want the unglamorous wins, not the "it wrote a FizzBuzz" flex. 🫡\n\n#AICoding #LLM #SoftwareEngineering #SWEbench #DevTools #AI', 'processed_post': '🧠 **Aight, let me tell you somethin\' about these AI benchmark scoreboards, fool.**\n\nSWE-bench went from 13 to 70-plus in *two years*. That\'s a whole era. HumanEval? Nah, that\'s solved. That\'s in the past. LiveCodeBench still climbin\', still tryna prove it\'s *real*.\n\nAnd yet — and this is the part that gets me — half the homies I\'m talkin\' to say their "top-ranked" AI assistant still can\'t handle a mid-sized refactor without hallucinatin\' a dependency outta nowhere. Just *made up* a package that don\'t exist. Straight up. And the scoreboard says "95 percent, fool."\n\nYou feel me?\n\n📊 These benchmarks? They\'re testin\' you on *one file, one problem, clean little box in the lab*. Neat. Tidy. G-funk smooth.\n\n🏗️ But the *real* codebase? That\'s the 3 a.m. sprint. Fifty thousand lines. Three devs who left in 2019. A variable named `tmp2_final_v3` that some dude made at 2 a.m. on a Friday and *nobody* can explain. Context everywhere. Mess. Beautiful, ugly, *real* mess.\n\nSo what are we actually puttin\' on the scoreboard, huh?\n\n- Pattern recognition? *Solved.* That\'s a 2019 move now.\n- Algorithmic reasoning? Gettin\' there. Respect.\n- *Engineering judgment?* — Knowin\' when to touch the function and when to leave the damn thing alone? Nah. Not even in the zip code.\n\nLet me break down what I *actually* wanna see in the next wave of evals, keep it 100:\n\n→ Multi-file, multi-step tasks where the requirements are *vague* — "fix the checkout flow" with no specs, just vibes\n→ Drop a bug in a 50k-line repo. No test suite. No docs. Just you and the terminal. *That\'s* the real test.\n→ Keep the existing code style. Even the *bad* one. Even the `if (x != null)` in a language where that\'s a crime. *Match the team, fool.*\n→ Knowin\' when to say "I don\'t got it" instead of confidently shippin\' garbage and callin\' it a feature\n\nThe benchmark arms race? Fun. I\'ll watch it. It\'s got *vibes*. But a model that slaps a 95 on HumanEval and then breaks your CI pipeline at 4 p.m. on a Tuesday? That ain\'t the model you *need*. That\'s the model they *market* to you on the billboard. Different energy.\n\nSo here\'s the question I\'m leavin\' on the block, and I want the *real* answer, not the highlight reel:\n\n**When was the last time an AI coding tool actually made you go "holy *hell*, that\'s smooth" on a real project? And what did it *actually* do right?**\n\nDrop it below. I wanna hear the unglamorous wins. The quiet "okay, that saved me forty minutes" moments. *Not* the "it wrote FizzBuzz and I posted it" flex. We seen that. We *done* with that. 🫡\n\nWord is bond. Keep it real out there.\n\n#AICoding #LLM #SoftwareEngineering #SWEbench #DevTools #KeepItReal #WestCoastCode'}

LangChain System, Human and AI Messages (Refresh)​

So far I have only used the single user query that LangGraph automatically interpreted as the HumanMessage:

from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage

client = ChatOpenAI(
model="qwen3.8:latest",
temperature=0.6,
top_p=0.95,
base_url="http://localhost:11434/v1",
api_key="ollama",
)

print(client.invoke([HumanMessage(content="Tell me an Haiku that sums up the popular television show 'Lost'.")]).content)
python -m langgraph_intro.03_langgraph_handling_messages

'Crashed on foreign sand,\nsecrets buried in the past—\nwhat happened, happened.'

AI generated messages between between different nodes can be appended - either manually or by the add operator:

from typing import Annotated
from operator import add

from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage

client = ChatOpenAI(
model="qwen3.8:latest",
temperature=0.6,
top_p=0.95,
base_url="http://localhost:11434/v1",
api_key="ollama",
)

# Create structured Schema

class graph_shema(BaseModel):
messages_system: SystemMessage = Field(description="This is the system message that is being added to every prompt.")
# If you want to append messages here instead of overwriting you have to do it manually
messages_manual: list = Field(description="This is the list of messages that get appended manually (no add reducer).")
# 'messages_auto' use the add operator to make sure that new messages are automatically appended
messages_auto: Annotated[list, add] = Field(description="This is the list of auto-added AI messages generated by the llm.")

demo = graph_shema(
messages_system=SystemMessage(content="You are a helpful agent."),
messages_manual=[HumanMessage(content="Tell me about yourself.")],
messages_auto=["What do you think about?"]
)

# Create Node functions

def generate_text(state: graph_shema) -> graph_shema:

messages_manual = list(state.messages_manual)
messages_auto = list(state.messages_auto)

# 'manual' = no reducer, so we append to the list explicitly
messages_manual.append(AIMessage(content=client.invoke(messages_manual).content))
state.messages_manual = messages_manual

response_auto_ai = [AIMessage(content=client.invoke(messages_auto).content)]
state.messages_auto = response_auto_ai

return state

def process_text(state: graph_shema) -> graph_shema:
messages_manual = list(state.messages_manual)

response_manual = client.invoke(messages_manual).content
messages_manual.append(AIMessage(content=client.invoke(response_manual).content))
state.messages_manual = messages_manual

return state

# Create the State Graph

## Adding Nodes
graph = StateGraph(graph_shema)
graph.add_node("generate_text", generate_text)
graph.add_node("process_text", process_text)
## Adding Edges
graph.add_edge(START, "generate_text")
graph.add_edge("generate_text", "process_text")
graph.add_edge("process_text", END)
graph = graph.compile()

# Run Graph

response = graph.invoke(demo) # expects graph_shema!

print(response)

LangChain Prompt Chaining (Refresher)​

from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage

client = ChatOpenAI(
model="qwen3.8:latest",
temperature=0.6,
top_p=0.95,
base_url="http://localhost:11434/v1",
api_key="ollama",
)

# Create the Prompt Template

my_prompt = ChatPromptTemplate.from_messages(
[
("system", "You are a helpful assistant that helps to create and curate posts for a social media platform."),
("human", "I want to create a post about {topic}.")
]
)

# DEBUG
chain = my_prompt | client
print(chain.invoke({"topic": "the movie Alien that reads like a joke and makes senior AI engineers laugh out loud"}).content)
python -m langgraph_intro.04_langgraph_prompts_chains

**Alien (1979) is just a movie about deploying a model to production. I'm not making this up.**

---

*Yes, I'm 44 and I rewatched Alien last night and cried a little. It's just a story about a model that shouldn't work but does, in an environment where you can't roll back, and the only person who can stop it is the one who was told to go home.*

- Your ship receives a signal you never asked for. (A weird input in prod at 2am.)
- You go to investigate. (You open the logs. The logs are empty.)
- You find something small. Unremarkable. (A config file that shouldn't exist.)
- It latches onto one crew member. (The bug hits one pod.)
- It leaves something inside you can't see. (The model is doing a thing. You don't know what.)
- The infected crew member acts *completely normal* for hours. (It passes every unit test. It passes the eval suite. It's *fine*.)
- Then it splits open in the cafeteria. (It breaks during the stakeholder demo. On the big screen.)
- The thing that comes out is a **perfect organism.** Zero waste. No byproducts. (The model is flawless and you have zero intermediate activations to debug.)
- It's in the walls. In the vents. You can't find it. (It's in your infra. It's a system process. You can't kill it.)
- The only solution is to eject it into the void and hope. (You roll back. You pray. You do not sleep.)
- And it **survives in space.** (It's still running on that old EC2 instance. Nobody has its credentials.)

The scariest line in the whole movie? *"It's a perfect organism."*

You know that feeling. You trained it on garbage. You gave it a loss function that makes no sense. And it just... *emerged.* And it's better than you. And you have no explanation. And it has no mouth.

No output layer.

And it still kills you.

LangChain reAct Agents (Refresher)​

As the title is saying this is just a refresher about agentic reasoning & action loops over optional tools to create a response - aka RAG. To add some spice I will try to attempt a real-world example of querying the ElasticSearch API of the INSTAR Support Wiki and - if the response does not satisfy the agent to create the response - I will add a web-scrape that retrieves the content of the corresponding web-pages - all HTML-parsed and truncated so that I don´t blast through my agents context window!

So first I need two helper functions - the web-scrape and HTML parsing:

import json
import re
import urllib.parse
import urllib.request
from html.parser import HTMLParser

class _TextExtractor(HTMLParser):
"""Collect visible text from HTML, skipping script/style blocks."""

SKIP_TAGS = {"script", "style", "noscript", "svg", "head", "template"}

# Sets up a counter for how many "skip" blocks (script/style/etc.) we are
# currently inside, plus the empty list that will collect visible text pieces.
def __init__(self) -> None:
super().__init__()
self._skip_depth = 0
self.parts: list[str] = []

# Called by HTMLParser for every opening tag; if it's one we want to skip
# (like <script> or <style>), bump the counter so that block's text is ignored.
def handle_starttag(self, tag, attrs):
if tag in self.SKIP_TAGS:
self._skip_depth += 1

# Called by HTMLParser for every closing tag; if it closes a "skip" block,
# lower the counter again so normal text resumes (a counter, not a boolean,
# also handles nested skip tags).
def handle_endtag(self, tag):
if tag in self.SKIP_TAGS and self._skip_depth > 0:
self._skip_depth -= 1

# Called by HTMLParser for each chunk of plain text; if we're not inside a
# skipped block, keep the trimmed text (empty chunks are dropped).
def handle_data(self, data):
if self._skip_depth == 0 and data.strip():
self.parts.append(data.strip())


def _http_get(url: str, timeout: int = 20) -> str:
"""Fetch a URL and return the body as text (our 'curl' call)."""
request = urllib.request.Request(
url, headers={"User-Agent": "Mozilla/5.0 (INSTAR-Intro/0.5)"}
)
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read().decode("utf-8", errors="replace")

And now the LangChain related code - the agent and the two optional tools:

from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain.agents import create_agent

API_BASE = "https://search.instar.wiki/wiki_en/_search"
WIKI_BASE = "https://wiki.instar.com/en"

client = ChatOpenAI(
model="qwen3.8:latest",
temperature=0.6,
top_p=0.95,
base_url="http://localhost:11434/v1",
api_key="ollama",
)


# The Prompt Template

SYSTEM_PROMPT = (
"You are a helpful assistant that answers questions about the INSTAR camera product line. "
"Use the search tool to find relevant English wiki pages. If the descriptions are not "
"detailed enough, use the scrape tool on one of the returned URLs to read the full page. "
"Base your final answer ONLY on the information you retrieved, and cite the URLs you used."
"Do not use any Markdown formatting in your response."
)


@tool
def search_instar_wiki(query: str, limit: int = 5) -> str:
"""Search the English INSTAR camera wiki via the official Elasticsearch API.

Takes a natural-language or keyword query and returns the top matching
pages, each with: title, relevance score, a short description and the
full page URL. Use the scrape_wiki_page tool on a URL if you need
more detail than the description provides.
"""
try:
url = f"{API_BASE}?q={urllib.parse.quote(query)}"
data = json.loads(_http_get(url))
hits = data["hits"]["hits"][:limit]
if not hits:
return f"No wiki pages found for: {query}"

results = []
for hit in hits:
source = hit.get("_source", {})
sublink = source.get("sublink1")
results.append(
{
"title": source.get("title"),
"score": round(hit.get("_score") or 0.0, 3),
"description": (source.get("description") or "")[:700],
# sublink1 already starts with "/"
"url": WIKI_BASE + sublink if sublink else None,
}
)
return json.dumps(results, ensure_ascii=False, indent=2)
except Exception as exc: # surface a readable error to the agent
return f"Search failed: {exc}"


@tool
def scrape_wiki_page(url: str) -> str:
"""Fetch a wiki.instar.com page and extract its readable text.

Only use URLs that search_instar_wiki returned. Returns the page text
with HTML stripped out (truncated if the page is very long).
"""
if not url.startswith("https://wiki.instar.com/"):
return "Refused: this tool only allows scraping pages from https://wiki.instar.com/"
try:
html = _http_get(url)
extractor = _TextExtractor()
extractor.feed(html)
text = re.sub(r"\s{2,}", " ", " ".join(extractor.parts))
return text[:5000] + ("... [truncated]" if len(text) > 5000 else "")
except Exception as exc:
return f"Scrape failed: {exc}"


if __name__ == '__main__':

# ReAct agent: handles the tool loop (search -> maybe scrape -> answer)
# and finishes with a final answer based on the retrieved information.
agent = create_agent(
client,
tools=[search_instar_wiki, scrape_wiki_page],
system_prompt=SYSTEM_PROMPT,
)
print("INSTAR WIKI SEARCH AGENT\n")
print("-----------------------------------------------------------------------------------\n")
print("Please type your question below, e.g. 'How do I configure the INSTAR MQTT Broker?':\n")
query = input()
print("===================================================================================\n")
result = agent.invoke({"messages": [("human", query)]})
print(result["messages"][-1].content)

Sweet! This is working:

python -m langgraph_intro.05_langgraph_react

INSTAR WIKI SEARCH AGENT

-----------------------------------------------------------------------------------

Please type your question below, e.g. 'How do I configure the INSTAR MQTT Broker?':

How does the passive infrared sensor (PIR) in my INSTAR camera work?
===================================================================================

Here's how the passive infrared (PIR) sensor in your INSTAR camera works, based on the INSTAR wiki:

Basic principle
A PIR (Passive Infrared / Pyroelectric Infrared) sensor does not emit anything itself; it passively detects changes in temperature within the surveillance window in front of the camera. It picks up the infrared (IR) radiation that warm bodies give off — your body heat, or a hot car engine hood, for example. This IR radiation has wavelengths between 1 µm and 1.5 mm, with a peak of emission around 10 µm, and it is invisible to the human eye. A PIR sensor essentially detects warm-bodied targets (mammals, birds) in motion; a completely stationary target cannot be detected. The target must have a surface temperature substantially different from the surrounding ambient temperature.

How it actually triggers
The sensor "sees" the moving heat signal through a Fresnel lens, which divides the field of view into small windows (rectangular beams). A simple PIR sensor triggers an alert once it sees a hot object leave one window and enter a neighbouring window. More sophisticated sensors apply small programs to rule out background "noise" as alarm triggers.

Range and coverage
- Indoor cameras (e.g. IN-8015 FHD, IN-8003 FHD, IN-8001 FHD) use a small PIR sensor with a horizontal angle of view of 120° and a range of about 5 m.
- Outdoor cameras (e.g. IN-9020 FHD, IN-9008 FHD) use a more powerful Panasonic PIR sensor, also with a 120° horizontal angle of view, but a range of about 12 m.

Sensitivity and installation notes
- The sensor is more sensitive when you walk past it (perpendicular to the lens) rather than directly toward it, so it's best mounted at a 90° angle to the side of an entry rather than directly in front of it.
- The Fresnel lenses are designed to "look" down, so mounting close to the ceiling is recommended; the ideal mounting height is between 1.80 and 2.50 metres.
- Avoid pointing it at heat sources, glass panes, or directly into sunlight, and keep it away from moving objects (curtains, plants, aquariums) and areas with air flow, as these can cause false alarms.
- It generally works more reliably at night and reaches its best range in the cold of winter.

Recommended best practice
The safest way to use your INSTAR camera is to couple the internal software-based motion detection with the PIR sensor. By activating sensor coupling in the Alarm/Actions menu, an alarm is only triggered when both detection methods agree that something is happening, which minimizes false alarms.

Sources:
https://wiki.instar.com/en/Motion_Detection/Passive_Infrared_Detection/
https://wiki.instar.com/en/Products/IN-Motion/500/Dual_Sensor/

LangGraph reAct Agents (NEW!)​

Now let's re-write this LangChain example to use the LangGraph StateGraph instead. Here I am breaking up the chain above into 2 node functions - one for the agent invocation and the other for tool calls - and a third function that provides the condition on wheter to stop the agentic loop and create the final response or to collect more data (see Github Repo for full code):

# ---------------------------------------------------------------------------
# The LangGraph ReAct loop
# ---------------------------------------------------------------------------

# Bind the model to the tools ONCE at module level. bind_tools() only adds
# the tool schemas to the model's output space — no network call happens here.
llm = client.bind_tools([search_instar_wiki, scrape_wiki_page])

# name -> tool lookup so tool_node can dispatch dynamically (this dict is
# the single place that maps a tool name to its function).
tools_by_name = {
"search_instar_wiki": search_instar_wiki,
"scrape_wiki_page": scrape_wiki_page,
}


def agent_node(state: graph_schema) -> dict[str, Any]:
"""ONE model call (no loop!). It either emits tool_calls or answers.

Takes the graph state in (a "messages" list, thanks to the AgentState
subclass above) and returns the partial update to merge back in — here
just the new AIMessage.
"""
response = llm.invoke(state["messages"])
return {"messages": [response]}


def tool_node(state: graph_schema) -> dict[str, Any]:
"""Execute the tool calls requested in the previous AIMessage.

It appends ONE ToolMessage per tool call, echoing the tool_call_id of
the corresponding AIMessage so the model can link them up.
"""
last_message = state["messages"][-1] # the AIMessage with tool_calls
tool_messages = []
for call in last_message.tool_calls:
tool_fn = tools_by_name.get(call["name"])
if tool_fn is None:
output = f"Tool not found: {call['name']}"
else:
try:
output = tool_fn.invoke(call["args"])
except Exception as exc:
# Keep the loop alive: hand the error back to the model
# instead of crashing the graph.
output = f"Tool call failed: {exc}"
tool_messages.append(
ToolMessage(
content=str(output),
name=call["name"],
tool_call_id=call["id"],
)
)
return {"messages": tool_messages}


def route_after_agent(state: graph_schema) -> str:
"""Conditional edge: keep the ReAct loop going or finish?

If the AIMessage carries tool_calls we must loop back through the
tools; otherwise there is nothing left to do and we are done.
"""
last_message = state["messages"][-1]
if getattr(last_message, "tool_calls", None):
return "tools"
return END


# Wire it up: START -> agent_node -"tools"-> tool_node -> agent_node (loop)
# └─END (no tool_calls)─┘
workflow = StateGraph(graph_schema)
workflow.add_node("agent_node", agent_node)
workflow.add_node("tool_node", tool_node)
workflow.add_edge(START, "agent_node")
workflow.add_conditional_edges(
"agent_node",
route_after_agent,
{"tools": "tool_node", END: END},
)
workflow.add_edge("tool_node", "agent_node") # always loop back to the agent

graph = workflow.compile()


if __name__ == '__main__':
print("INSTAR WIKI SEARCH AGENT\n")
print("-----------------------------------------------------------------------------------\n")
print("Please type your question below, e.g. 'How do I configure the INSTAR MQTT Broker?':\n")
query = input()
print("===================================================================================\n")
# The messages list may hold any message type (that's what list[AnyMessage]
# in the state means): here we put the system prompt in first as a
# SystemMessage, then the user's question as a HumanMessage.
# (SystemMessage and HumanMessage both live in langchain_core.messages,
# just like AIMessage and ToolMessage.)
result = graph.invoke(
{
"messages": [
SystemMessage(content=SYSTEM_PROMPT),
HumanMessage(content=query),
]
}
)
print(result["messages"][-1].content)

And the result is identical:

python -m langgraph_intro.05_langgraph_react

INSTAR WIKI SEARCH AGENT

-----------------------------------------------------------------------------------

Please type your question below, e.g. 'How do I configure the INSTAR MQTT Broker?':

Where can I configure the object detection for my INSTAR camera and what options are available?
===================================================================================

You can configure object detection in the camera's web user interface, under the Alarm menu. Specifically, there are two related menus you work with:

1. Alarm Actions menu – This is where you first activate the software-based object detection for your camera.

2. Object Detection menu – This is where you fine-tune the settings. The available options there are:

- Object selection: Choose which object type you want to tune the detection for — Person, Vehicle, or Animal. Each type has its own detection threshold and bounding box settings.

- Bounding boxes (size limits): You place two bounding boxes on your video image. The outer box defines the maximum size an object may be to trigger an alert, and the inner box defines the minimum size required. Note that only the width and height matter — the position of the boxes is not important; they are used to set the size range. You can use the "Show bounding box" option to display and adjust the minimum and maximum boxes with your mouse.

- Threshold: The object detection algorithm returns a percentage indicating how certain it is that a detected object is a person, vehicle, or animal. You set the threshold to define how certain the algorithm must be before it triggers an alert.

Access permissions: To use this menu, the logged-in user needs at least the "Alarm Settings" and "Live Stream" user permissions, which can be adjusted in the user menu.

Sources:
- https://wiki.instar.com/en/Web_User_Interface/1440p_Series/Alarm/Object_Detection/
- https://wiki.instar.com/en/Web_User_Interface/1440p_Series/Alarm/Actions/