← Back to Terminal Agent

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.

CORE MECHANISM COMPLETE · TERMINAL + HARBOR INTEGRATION OPEN

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

Decision Build the loop first with a trivial, obviously-fake tool (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.

Alternative considered Jump directly to a 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.name and .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:

  1. The original user question
  2. The model's tool-call message
  3. 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 real run_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.