AI/Agents

Building a Small Agent Loop with Tool Calling

Before reaching for an agent framework, I built the loop by hand in about eighty lines. It made every abstraction the frameworks provide obvious.

Every agent framework I looked at solves the same core problem, wrapped in a different amount of ceremony: ask a model what to do, let it call a tool if it needs to, feed the result back, repeat until it says it's done. I wanted to understand that loop well enough to debug it, so I built the smallest version I could, without a framework.

The Loop Itself

At its core an agent loop is a while loop with a message list that keeps growing:

def run_agent(user_input: str, tools: dict, max_steps: int = 6) -> str:
    messages = [{"role": "user", "content": user_input}]

    for _ in range(max_steps):
        response = call_model(messages, tool_specs=list(tools.values()))

        if response.tool_call is None:
            return response.content  # model is done, this is the final answer

        tool_fn = tools[response.tool_call.name]["fn"]
        result = tool_fn(**response.tool_call.arguments)

        messages.append({"role": "assistant", "tool_call": response.tool_call})
        messages.append({"role": "tool", "name": response.tool_call.name, "content": str(result)})

    return "Gave up after max_steps without a final answer."

Nothing in there is exotic. The model decides between "call a tool" and "give a final answer" on every turn, and the loop just keeps appending to the transcript until one of those happens or it runs out of steps. max_steps exists because an under-specified tool or a confused model can loop indefinitely otherwise — it's the cheapest safety net available.

Tool Definitions Are Just Schemas Plus Functions

The tools dict pairs a JSON schema (what the model sees) with an actual Python function (what runs):

tools = {
    "search_docs": {
        "spec": {
            "name": "search_docs",
            "description": "Search internal documentation by keyword",
            "parameters": {
                "type": "object",
                "properties": {"query": {"type": "string"}},
                "required": ["query"],
            },
        },
        "fn": search_docs,
    },
}

The model never runs search_docs itself — it only ever produces a request to run it, with arguments matching the schema. The actual execution, and the decision of whether to trust the arguments at all, stays entirely in code I control. That boundary is the whole safety story: never execute a tool call whose arguments you haven't validated, especially anything that touches the filesystem or the network.

The Go Version, for Comparison

I ported the same loop to Go mostly to see what the type system would force me to make explicit, and it was useful — Go makes the "what shape is a tool call" question unavoidable up front instead of something you can leave implicit in a dict:

type ToolCall struct {
	Name      string
	Arguments map[string]any
}

func RunAgent(input string, tools map[string]Tool, maxSteps int) (string, error) {
	messages := []Message{{Role: "user", Content: input}}

	for i := 0; i < maxSteps; i++ {
		resp, err := callModel(messages, toolSpecs(tools))
		if err != nil {
			return "", err
		}
		if resp.ToolCall == nil {
			return resp.Content, nil
		}

		tool, ok := tools[resp.ToolCall.Name]
		if !ok {
			return "", fmt.Errorf("unknown tool: %s", resp.ToolCall.Name)
		}
		result := tool.Fn(resp.ToolCall.Arguments)
		messages = append(messages, Message{Role: "assistant", ToolCall: resp.ToolCall})
		messages = append(messages, Message{Role: "tool", Name: resp.ToolCall.Name, Content: result})
	}
	return "", errors.New("max steps exceeded without a final answer")
}

The explicit ok check on an unknown tool name is something the Python version only got by accident, via a KeyError. Writing it out loud in Go made me go back and add the same explicit check to the Python version.

What This Made Obvious About Frameworks

Once the loop existed in eighty lines, it was much easier to read a framework's source and see which parts are genuinely solving hard problems — retry logic, streaming partial tool calls, parallel tool execution — versus which parts are just this same loop with extra configuration surface. I still reach for a framework for anything going to production, but I trust myself to debug it now, because I've seen the loop with nothing hidden.

Getting the model to reliably choose the right tool, rather than a plausible-sounding wrong one, turned out to matter more than the loop mechanics — that's a longer story I cover in the prompting post here.