Minimal Agent: Understanding Tool Calling from Scratch
Building the smallest possible agent loop without a framework, starting with a fake tool before connecting the system to a real terminal environment.
1. Goal for this session
Phase 1 validated the measurement instrument (Harbor + Terminal-Bench), but I hadn't written a single line of agent code yet.
The goal for Phase 2 is to build the smallest possible working agent loop, with no framework, so I understand exactly what every later abstraction — LangGraph, ADK, and others — would be hiding from me.
Today's narrower goal was to understand tool calling: the mechanism that lets a language model request an action instead of simply replying with text.
2. Why start with a fake tool instead of the real shell
get_weather) before touching real shell execution.
Tool calling introduces several moving parts:
- the tool schema
- the model's tool-call response
- JSON parsing of arguments
- executing the requested function
- sending the result back to the model
Debugging all of that simultaneously with
subprocess, exit codes, and shell quoting would
conflate two separate sources of bugs.
The fake tool isolates the tool-calling mechanism first.
run_command tool using
subprocess.
I rejected that approach because any failure would be ambiguous: was the problem with my understanding of tool calling or with shell execution?
The principle is simple: one variable at a time.
3. The mental model: waiter and kitchen
- The model = a waiter. It can ask for something, but cannot cook.
- The tool schema = a menu. It describes what's available but contains no execution logic.
- My Python function = the kitchen. This is where the actual work happens.
- The model's tool-call response = the waiter placing an order.
-
Reading
tool_call.function.nameand.arguments= receiving that order. - Calling the function = the kitchen preparing the result.
-
Sending a
role: "tool"message = carrying the result back to the waiter.
The model never executes the function itself. It only requests an action. The application code performs the actual execution.
4. What I built
I used the OpenAI SDK rather than introducing another SDK at the same time. The objective here was to understand the mechanism, not learn a new provider interface.
4.1 — The real function
def get_weather(city: str) -> str:
return f"It's sunny and 72°F in {city}."
4.2 — The tool schema
The schema is just data. It tells the model that the tool exists, what it does, and which arguments it expects.
weather_tool = {
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city.",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name"
}
},
"required": ["city"]
}
}
}
4.3 — The full loop
messages = [
{
"role": "user",
"content": "What's the weather in Paris?"
}
]
for step in range(5):
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=[weather_tool]
)
message = response.choices[0].message
if message.tool_calls:
tool_call = message.tool_calls[0]
messages.append(message)
args_dict = json.loads(
tool_call.function.arguments
)
result = get_weather(
args_dict["city"]
)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result
})
continue
else:
print(
"Final answer:",
message.content
)
break
4.4 — Traced output
=== STEP 0 ===
Sending 1 message(s) to the model...
Model requested tool: get_weather
With arguments:
{"city":"Paris"}
Tool actually returned:
It's sunny and 72°F in Paris.
Appended tool result to messages.
Looping again...
=== STEP 1 ===
Sending 3 message(s) to the model...
Model gave a plain-text answer —
no more tools needed.
Final answer:
The weather in Paris is currently
sunny with a temperature of 72°F.
The message count went from 1 → 3 during one tool-call iteration:
- The original user question
- The model's tool-call message
- The tool-result message
5. Debugging log
I'm intentionally keeping the wrong turns. They are more useful for understanding the system than pretending the final code appeared fully formed.
| Mistake | What I thought | What was actually true |
|---|---|---|
| Built the tool schema as a function | A tool must be callable. | The schema sent to the API is a description. The Python function is a separate execution layer. |
| Looked inside the tool definition for the model's answer | The model's request would appear inside the tool definition. | The tool definition never changes. The actual request appears in the API response. |
Checked message.role == "tool" |
I needed to detect a tool result. | The model's message is an assistant message. My application creates the tool-result message. |
Called get_weather() without arguments |
The arguments would somehow be passed automatically. | Tool arguments arrive as a JSON string and must be parsed. |
| Called the function but saw no output | Calling a function prints its result. |
return passes a value back.
Printing requires explicitly displaying the returned value.
|
6. Key insight: the model trusts the tool
The model has no way to know that
get_weather is a stub that ignores real weather data.
It sees a tool result and treats that result as information supplied by the tool.
That exposes an important reliability issue: a tool that silently returns incorrect data can be just as dangerous as a model hallucination.
Everything downstream depends on the result returned by the execution layer.
This is one reason the next step — connecting the loop to a real shell — is important. The shell gives the agent an actual execution environment rather than a fabricated result.
7. Why did the model call the tool?
In this experiment, the model chose to use the available weather tool rather than answer directly.
That makes intuitive sense: live weather changes continuously, while the model's internal knowledge is not a live data source.
However, this observation came from one run with one model. I therefore treat the stronger claim about why the model made this choice as a hypothesis, not a universal fact.
8. What's next
This session demonstrated the tool-calling mechanism, but Phase 2's full deliverable is a minimal working terminal agent.
-
Replace
get_weather()with a realrun_command()tool. - Execute commands inside the Terminal-Bench environment.
-
Wire the loop into Harbor's
BaseAgent.run()interface. - Run the agent against a real Terminal-Bench task.
- Record the first honest baseline score: V0.
Architecture Decision Record: External Agent vs. Installed Agent
Decision:
Build the agent as a Harbor External Agent
(BaseAgent subclass).
Why:
run() receives the environment directly and expects
the caller to invoke environment.exec() itself.
This exposes the exact loop being built here.
Alternative:
Installed Agent (BaseInstalledAgent).
Tradeoff:
External Agent requires no container-packaging step,
but provides less parity with production agents that
run headless inside their environment.
Decision:
Use External Agent for Phase 2.
Reason:
Deployment-style packaging belongs to Phase 7,
not Phase 2.