Module 2 · Lesson 2 | Tools and the Observation Loop
📌 Read time: ~8 min | Module 2 of 8
Tools and the Observation Loop
The agent thinks. Python acts. You connect them.
In Lesson 1 you saw the ReAct loop: Thought → Action → Observation → Thought. This lesson zooms in on the middle part — the Action. Specifically: what is a tool, how does the agent choose which one to call, and how does the result come back into the loop?
By the end you will have a working tool registry and a parser you can drop straight into the agent.
What is a tool?
Forget the word 'tool' for a second. A tool is just a Python function.
But there is one extra ingredient: a plain-English description that the LLM reads. That description is how the agent knows the function exists and what it does. Without it, the LLM cannot see the function at all.
So a tool = Python function + description. Nothing else. No special SDK, no framework magic.
Networking analogy: think of a tool like an SNMP OID.
The OID is the address of a piece of data. The description is the MIB entry — it explains
what that OID gives you. You register it once. The agent decides when to poll it.
The description has exactly one job: help the agent self-select the right tool. Write it the way you would label a show command for a junior engineer — specific and unambiguous.
Vague description → agent calls the wrong tool. Precise description → agent calls the right one.
The TOOLS registry — your toolbox
All your tools live in one Python dict called TOOLS. Each entry has three things:
• "function" — the actual Python function to run
• "description" — what the LLM reads to decide whether to pick this tool
• "params" — what arguments the function needs, explained in plain English
Here is the registry with two tools:
# ── The functions (demo data — replace with Netmiko in production) ──
def show_ospf_neighbors(device: str) -> str:
# In production: SSH to device, run 'show ip ospf neighbor'
return f"{device} Gi0/1: neighbor 10.0.0.2 State INIT Dead 34"
def ping_device(device: str, target: str) -> str:
# In production: SSH to device, run 'ping {target}'
return f"{device} -> {target}: Success rate 0%, 0/5 packets"
# ── The registry ──
TOOLS = {
"show_ospf_neighbors": {
"function": show_ospf_neighbors,
"description": "Get OSPF neighbor table from a router",
"params": {"device": "Device hostname (string)"},
},
"ping_device": {
"function": ping_device,
"description": "Ping a target IP from a device",
"params": {"device": "Source device hostname",
"target": "Target IP address"},
},
}
✅ To add a new tool: add one entry to TOOLS. Nothing else in your code changes.
⚠️ The 'function' key holds the function object itself — no quotes, no parentheses.
Python calls it later with: TOOLS["tool_name"]["function"](**params)
How the description reaches the LLM
The LLM cannot see your TOOLS dict directly. You have to convert it to text and inject it into the system prompt on every API call. This helper does that:
def build_tools_desc() -> str:
lines = []
for name, info in TOOLS.items():
lines.append(f"Tool: {name}")
lines.append(f" Description: {info['description']}")
lines.append(f" Params: {info['params']}")
lines.append("") # blank line between tools
return "\n".join(lines)
What comes out of build_tools_desc() looks like this:
Tool: show_ospf_neighbors
Description: Get OSPF neighbor table from a router
Params: {'device': 'Device hostname (string)'}
Tool: ping_device
Description: Ping a target IP from a device
Params: {'device': 'Source device hostname', 'target': 'Target IP address'}
This text block gets injected into the system prompt where it says {tools_description}. The LLM reads it and knows exactly which tools exist, what each one does, and what parameters to pass.
🧠 You are not writing if/else logic to pick the tool — the LLM does that.
Your job is to write descriptions clear enough that the right choice is obvious.
The better your descriptions, the fewer mistakes the agent makes.
Parsing the LLM response
The LLM returns plain text. Your loop cannot act on plain text — it needs to know: which tool? which params? or is the agent done?
Here is what the LLM output actually looks like mid-loop:
Thought: INIT confirmed on Gi0/1. I need to check R2's side next.
Action: show_ospf_neighbors
Params: {"device": "R2"}
The parser reads that text and turns it into a Python dict your loop can act on. It does three things in order:
• Step 1 — check for Final Answer. If the agent says it is done, extract the answer and stop the loop.
• Step 2 — extract the tool name. Find the word after 'Action:' using a regex.
• Step 3 — extract the params. Find the JSON block after 'Params:' and parse it.
Here is the code:
import re, json
def parse_response(text: str) -> dict:
# Step 1: is the agent done?
if "Final Answer:" in text:
answer = text.split("Final Answer:")[-1].strip()
return {"type": "final", "answer": answer}
# Step 2: get the tool name
tool_match = re.search(r"Action:\s*(\w+)", text) # Step 3: get the parameters
params_match = re.search(r"Params:\s*({.*?})", text, re.DOTALL) tool_name = tool_match.group(1).strip() if tool_match else None params = json.loads(params_match.group(1)) if params_match else {} return {"type": "action", "tool": tool_name, "params": params}
The function always returns one of two shapes:
• {"type": "final", "answer": "..."} — agent is done, return the diagnosis
• {"type": "action", "tool": "...", "params": {...}} — run the named tool
⚠️ re.DOTALL on the params search matters.
It tells the regex to match across line breaks.
Without it, any JSON that spans two lines silently returns empty params.
The agent then calls the tool with wrong or missing arguments.
Calling the tool — two lines
Once the parser gives you the tool name and params, executing the tool is two lines:
tool_fn = TOOLS[parsed["tool"]]["function"] # grab the function from the registry
result = tool_fn(**parsed["params"]) # call it with the extracted params
# result is a plain string — whatever the function returned
# e.g. 'R2 Gi0/0: neighbor 10.0.0.1 State INIT Dead 34'
The ** syntax in Python means 'unpack the dict as keyword arguments.' So tool_fn(**{"device": "R2"}) is exactly the same as tool_fn(device="R2"). This lets the loop call any tool without knowing its argument names in advance.
Feeding the result back — the Observation
After the tool runs, the result goes back into the conversation as the next message. This is the Observation. The LLM reads it and reasons again.
# Add what the LLM just said
messages.append({"role": "assistant", "content": raw_llm_output})
# Add the tool result as the next user message
messages.append({"role": "user", "content": f"Observation: {result}"})
Two things to notice here:
• The Observation is injected as a user message — not an assistant message. The LLM sees it as input from the outside world, not its own words.
• The full conversation history is sent on every API call. That history is the agent's memory. It knows what it already tried because it can read back every Thought and Observation.
🧠 No Observation appended = the LLM never sees the tool result.
It will call the same tool again with the same params. Infinite loop.
Always append the Observation before the next API call.
One mental picture — this is exactly what you do manually
Map it to how you actually troubleshoot OSPF in a CLI:
You think → 'INIT state. One-way Hellos. Check the neighbor table.'
You act → run: show ip ospf neighbor on R1
You observe → 'Gi0/1 is INIT with 10.0.0.2'
You think → 'Check R1's OSPF config on that interface.'
You act → run: show ip ospf interface Gi0/1 on R1
You observe → 'Area 0, Hello 10, Dead 40, MTU 1500'
You think → 'R1 is Area 0. Check R2's side.'
You act → run: show ip ospf interface Gi0/0 on R2
You observe → 'Area 1 — mismatch!'
You conclude → Root cause: area mismatch. Fix: align both to Area 0.
The agent does exactly this. The LLM plays the role of your thinking. Python plays the role of your CLI. The loop connects them. In production, the tools call Netmiko or NAPALM instead of returning demo strings.
❌ Four mistakes that break the loop
• Vague tool description. The LLM picks the wrong tool or makes up a tool name that does not exist.
• Missing re.DOTALL. Multi-line JSON params fail silently. Tool gets called with empty params.
• Not appending the Observation. The LLM never sees the tool result. It repeats the same action forever.
• No max_iterations limit. A parse error or LLM confusion leads to an infinite loop with no exit.
✅ What you built in this lesson
• A TOOLS registry — functions + descriptions the LLM reads to make decisions.
• build_tools_desc() — converts the registry to text and injects it into the system prompt.
• parse_response() — extracts tool name, params, or Final Answer from raw LLM output.
• The observation pattern — tool result → user message → next LLM call.
👇 What's next: Lesson 3 — Run It: Your First Diagnosis
In Lesson 3 you wire everything together, run the full agent loop on a live OSPF
ticket, and read every line of the trace to understand exactly what the LLM
is doing at each step.