A couple of weeks ago my RF agent had a very simple LangGraph.
It looked roughly like this: The graph you see bellow was sufficant along with a long list of DSP tools to do the fallowing 2 experiments:
- https://netchosis.com/2026/09/20/combining-langgraph-agent-and-sdr-for-fun/
- https://netchosis.com/2026/09/26/first-agenttic-analysis-of-basically-unknown-signal-from-capture-to-characterization/

At the time, this seemed almost trivial.
The model receives the conversation. If it wants to use one of the RF-analysis tools, LangGraph sends the requested tool call to call_tools. The tool result comes back to the model, the model reasons about the result, and eventually it produces an answer without requesting another tool.
In code, the important shape was basically:
builder.add_node("call_model", self.call_model)builder.add_node("call_tools", ToolNode( available_tools, handle_tool_errors=True, wrap_tool_call=self.state_updater.update, awrap_tool_call=self.state_updater.update_async,))builder.add_edge(START, "call_model")builder.add_conditional_edges("call_model", ...)builder.add_edge("call_tools", "call_model")
The line I did not fully appreciate yet was:
builder.add_edge("call_tools", "call_model")
That isn’t just a convenient loop.
It is the basic tool-calling protocol. Used by nearly all simple AI agents today
The model asks for a tool. The tool runs. The resulting ToolMessage has to go back to the model so the model can see what happened.
That part of my original graph was actually right.
I Added the Idea of an RF Survey
The RF agent was becoming more than a collection of analysis tools.
I wanted to be able to say something like:
Survey 70mhz – 1ghz and find anything intresting
A survey has state that ordinary one-off analysis doesn’t necessarily have.
For example, I wanted to create a survey ID and a directory where captures belonging to that survey could be stored. A survey can span multiple IQ and SigMF files
So I added an init_survey node.
The node itself was extremely simple:
def init_survey(self, state: RFState): survey_id = str(uuid4()) survey_dir = os.getcwd() + "/" + survey_id if not os.path.exists(survey_dir): os.makedirs(survey_dir) print(f"Survey ID: {survey_id}") print(f"Survey Dir: {survey_dir}") return { "survey": { "survey_id": survey_id, "survey_dir": survey_dir, }
There wasn’t really anything mysterious about this code.
The difficult part was figuring out where this operation belonged in the graph.And that turned into a much better LangGraph lesson than I expected.
Mistake #1: Treating Survey Routing Like Model Routing. My first instinct was to bolt survey detection onto the graph I already had.
Something along these lines:
builder.add_edge(START, "call_model")builder.add_conditional_edges( "call_model", self.is_survey, self.control_map,)This was the routing function staticmethoddef is_survey(state: RFState) -> Literal["survey", "model"]: select = state["messages"][-1] if isinstance(select, HumanMessage) and "survey" in str(select.content).lower(): return "survey" return "model"
This is the “pathmap” I called it self.control_map
sefl.control_map = { "tools": "call_tools", "survey": "init_survey", "model": "call_model", END: END,}
resulting graph …. Thats not right!

The model runs, then some routing function determines whether we’re doing a survey, calling tools, continuing through the model, or ending.
The problem was subtle but fundamental:
is_survey() was being called after call_model.
At that point the latest message was no longer necessarily the human request.
It was an AIMessage.
I was trying to use one router to answer two completely different questions:
- What kind of request did the human make?
- What should happen after the model responds?
Those are not the same routing decision.And they don’t even operate on the same kind of message.

Mistake #2
At this point I tried to fix the design by moving survey detection to the beginning of the graph, but I only fixed half of the problem. I added a smaller SURVEY_MAP at START, where is_survey() actually made sense because the latest message was still the user’s HumanMessage. But I also left that same is_survey() router attached to call_model and reused the larger CONTROL_MAP. That is what produced the graph above. After call_model ran, the newest message was an AIMessage, so is_survey() could no longer recognize it as a survey request and simply returned "model". The control map then sent "model" straight back to call_model, creating the self-loop. The visualization also showed tools, survey, model, and END as possible post-model routes because they were all present in CONTROL_MAP, even though is_survey() wasn’t really the right function to make all of those decisions. I had started separating the two kinds of routing, but I was still using the same router in both places.
The routing function itself was:
staticmethoddef is_survey(state: RFState) -> Literal["survey", "model"]: select = state["messages"][-1] if isinstance(select, HumanMessage) and "survey" in str(select.content).lower(): return "survey" return "model"
Pathmaps + new edges:
self.control_map = { "tools": "call_tools", "survey": "init_survey", "model": "call_model", END: END,}self.survey_map = { "survey": "init_survey", "model": "call_model",}
The Mistake Was essentially:
builder.add_conditional_edges( START, self.is_survey, SURVEY_MAP,)builder.add_conditional_edges( "call_model", self.is_survey, self.control_map,)builder.add_edge("call_tools", END)builder.add_edge("init_survey", END)
Once call_model had produced an AIMessage, the HumanMessage check failed, "model" was returned, and the graph sent itself right back into call_model. The resulting graph looks like this:

The important thing is that the graph looked like it had several possible routes, but at runtime it behaved much more simply—and much worse.
For a normal request, START called is_survey(). If the user’s message didn’t contain "survey", it returned "model", which correctly sent execution to call_model. The model then generated an AIMessage. But after that, I called is_survey() again. Since the newest message was now an AIMessage, this check could never succeed:
Mistake #3
To correct that, I separated the two routing decisions. Survey detection moved to START, where is_survey() could still inspect the original HumanMessage and decide whether the request should enter the survey path or the normal model path. After call_model, I used a different router whose job was only to inspect the model’s response and decide whether it had requested a tool or was finished. The goal was to eliminate the accidental call_model -> call_model loop by making each router responsible for one kind of decision. That part was the right idea, but in trying to make the graph terminate cleanly I went too far and routed call_tools directly to END, which introduced the next problem.
code: You will have to excuse the switch from pathmaps maps as class variables to constants
# First router:# This now runs at START, while the latest message is still# the user's HumanMessage.staticmethoddef is_survey(state: RFState) -> Literal["survey", "model"]: select = state["messages"][-1] if isinstance(select, HumanMessage) and "survey" in str(select.content).lower(): return "survey" return "model"# Second router:# This runs after call_model and asks a completely different question:# did the model request a tool, or is it finished?staticmethoddef after_model(state: RFState) -> Literal["tools", "__end__"]: select = state["messages"][-1] if select.tool_calls: return "tools" return END# Routing at START is now only about the user's intent.SURVEY_MAP = { "survey": "init_survey", "model": "call_model",}# Routing after call_model is now only about the model's response.CONTROL_MAP = { "tools": "call_tools", END: END,}# The two routing decisions are finally separated.builder.add_conditional_edges( START, self.is_survey, SURVEY_MAP,)builder.add_conditional_edges( "call_model", self.after_model, CONTROL_MAP,)# This was the remaining mistake:# after running a tool, we ended the graph instead of# returning the ToolMessage to call_model.builder.add_edge("call_tools", END)builder.add_edge("init_survey", END)
Resulting Still incorrect graph

This Fallowing is how I finally fixed it.
The final fix was to keep the routing responsibilities separated and restore the tool loop that I had accidentally removed. is_survey() stayed at START, where it only decides whether the original HumanMessage should begin a survey or go through the normal model path. After call_model, a different router looks at the resulting AIMessage: if the model requested a tool, execution goes to call_tools; if it did not, the graph is done and goes to END. The crucial change was replacing call_tools -> END with call_tools -> call_model. Now a tool can run, add its ToolMessage result to state, and return control to the model so it can reason about that new information, request another tool if necessary, or finish. That gives us the graph we have now: survey routing happens once at the beginning, while the model and tools form a deliberate reasoning loop that only ends when the model no longer asks for another tool.
# Route the original user request before the model runs.staticmethoddef is_survey(state: RFState) -> Literal["survey", "model"]: select = state["messages"][-1] if isinstance(select, HumanMessage) and "survey" in str(select.content).lower(): return "survey" return "model"# Route the model's response after call_model runs.staticmethoddef after_model(state: RFState) -> Literal["tools", "done"]: select = state["messages"][-1] if select.tool_calls: return "tools" return "done"# Entry routing: user intent only.SURVEY_MAP = { "survey": "init_survey", "model": "call_model",}# Post-model routing: model behavior only.CONTROL_MAP = { "tools": "call_tools", "done": END,}builder.add_conditional_edges( START, self.is_survey, SURVEY_MAP,)builder.add_conditional_edges( "call_model", self.after_model, CONTROL_MAP,)# Tool results go back to the model so it can reason about them.builder.add_edge("call_tools", "call_model")# Survey initialization currently ends here.builder.add_edge("init_survey", END)
The resulting graph: Correct !

The funny part is that after all of this, the RF agent still does not actually perform a survey yet. init_survey only creates the survey state and gives the graph somewhere to enter that workflow. But getting that one node into the graph correctly forced me to understand a lot more about how LangGraph control flow actually works: where user intent should be classified, how model responses should be routed, and why tool execution has to feed results back into the model. It was a surprising amount of work just to add one node, but that work created the place where the real survey logic can now begin. From here, I can start designing the agentic part of the survey itself: choosing frequencies, bandwidth, dwell time, deciding when to capture IQ, reacting to what the analysis finds, and eventually letting the agent decide what measurement is worth taking next.