AI Agent Beginner Tutorial: 04 - Function Calling and the Agent Loop

Connect a local search tool to the simple agent and implement tool schemas, call requests, allowlisted execution, result return, and a multi-turn loop. Success, failure, and budget-exhaustion exits give the project complete agent control flow for the first time.

Contents14 sections

Complete one tool call first

brief-agent already has TextModel, Goal, State, and Action, but the model still cannot access external evidence. Adding search_local_sources lets the model request evidence when needed.

The scope stays intentionally narrow: one tool, one call, and one fixed corpus. Multiple turns, retries, and permissions come later. Seeing one round trip clearly keeps the tool system from becoming a black box.

Prepare a reproducible local corpus

Use a dictionary as the corpus so changing network results do not affect the exercise.

PYTHON
DOCUMENTS = {
    "agent-loop": "Agent loops repeat observation, decision, action, and evaluation.",
    "workflow": "Workflows follow code-defined paths and finite branches.",
}

def search_local_sources(query: str) -> dict:
    words = set(query.lower().split())
    matches = []
    for source_id, text in DOCUMENTS.items():
        score = len(words & set(text.lower().split()))
        if score:
            matches.append({"source_id": source_id, "text": text, "score": score})
    matches.sort(key=lambda item: (-item["score"], item["source_id"]))
    return {"matches": matches[:3]}

This function has no model dependency. It receives ordinary arguments and returns serializable JSON. Run search_local_sources("agent loops") by itself and confirm stable input and output before exposing it to the model.

Describe the tool with JSON Schema

The model sees a name, description, and parameter schema. Strict mode requires the object to reject extra fields and list required properties.

PYTHON
TOOLS = [{
    "type": "function",
    "name": "search_local_sources",
    "description": "Search approved local documents and return at most three matches.",
    "strict": True,
    "parameters": {
        "type": "object",
        "properties": {
            "query": {"type": "string", "description": "Terms to search for"}
        },
        "required": ["query"],
        "additionalProperties": False,
    },
}]

The description states the action, source boundary, and result limit. The model uses it to choose when to call, while the application uses the schema to validate shape. Resource existence and user authorization still belong inside the tool function.

Follow one tool-call round trip

One complete round trip contains two model requests.

TEXT
┌────────┐ ① question ┌────────┐
│  User  │ ─────────▶ │ Model  │
└────────┘             └───┬────┘
                           │ ② function_call
                           ▼
                      ┌────────────┐
                      │ App runner │
                      └────┬───────┘
                           │ ③ function_call_output
                           ▼
                      ┌────────┐
                      │ Model  │
                      └───┬────┘
                          │ ④ final answer
                          ▼
                      ┌────────┐
                      │  User  │
                      └────────┘

The model does not execute a Python function. It returns a function_call; the application parses arguments, finds an allowlisted tool, executes it, and returns the result with the same call_id.

Write the two requests

PYTHON
import json
import os
from openai import OpenAI

client = OpenAI()
inputs: list = [{"role": "user", "content": "Find a source that explains agent loops."}]

first = client.responses.create(
    model=os.environ["OPENAI_MODEL"],
    tools=TOOLS,
    input=inputs,
)
inputs += first.output

for item in first.output:
    if item.type != "function_call":
        continue
    if item.name != "search_local_sources":
        result = {"ok": False, "error": "unknown_tool"}
    else:
        arguments = json.loads(item.arguments)
        result = {"ok": True, "data": search_local_sources(**arguments)}
    inputs.append({
        "type": "function_call_output",
        "call_id": item.call_id,
        "output": json.dumps(result),
    })

final = client.responses.create(
    model=os.environ["OPENAI_MODEL"],
    tools=TOOLS,
    input=inputs,
)
print(final.output_text)

inputs += first.output preserves every item produced by the model. A reasoning model may return reasoning items, and dropping them breaks the next request's context.

Read the run log

Logs record at least response ID, call ID, tool name, and success status. They do not record API keys or unredacted private material.

TEXT
response.created  response_id=resp_01
tool.requested    call_id=call_01 name=search_local_sources
tool.completed    call_id=call_01 ok=true matches=1
response.completed response_id=resp_02

If the model answers directly, first.output contains no function_call, and the program uses first.output_text. If an unknown tool appears, the application returns a structured error. It never uses eval or dynamic imports to execute a name supplied by the model.

Add three small tests

  • The tool function returns the same ordering for the same input.
  • A valid function_call finds the allowlisted function and returns the same call_id.
  • An unknown tool returns unknown_tool, and the runner invokes no function.

After one tool round trip, the model can change the program's next step. The next section places this code in a loop so the model can refine its search after results arrive and adds three explicit exit paths.

Why one tool call is insufficient

Technical research rarely ends after one search. The model may search for a topic and then read a matched document. If evidence remains insufficient, it may change terms and search again. The earlier single function-calling exchange hard-coded one “request tool—return result” exchange, leaving no place for a third action.

The following implementation places those fixed request blocks inside a loop. Every turn performs the same actions: call the model, inspect output, execute tools, or finish.

TEXT
             ┌──────────────────────┐
             │ Call model, read output│
             └──────────┬───────────┘
                        ▼
              ┌──────────────────┐
              │ function_call?    │
              └──────┬─────┬─────┘
                    yes     no
                     │       │
                     ▼       ▼
              Run allowlisted  Return final text
                   tool
                     │
                     ▼
              Append tool result
                     │
                     └──────▶ Next turn

Keep only necessary loop state

The minimal version stores model-facing history in inputs and writes a separate event log. They serve different purposes: inputs supports the next inference, while events support debugging and evaluation.

The loop also needs two counters: model turns and tool calls. A tool error may still lead to another turn, so the budgets cannot be merged into one number.

Write a general tool dispatcher

PYTHON
import json
from collections.abc import Callable

Tool = Callable[..., dict]

def dispatch_tool(name: str, arguments_json: str, tools: dict[str, Tool]) -> dict:
    tool = tools.get(name)
    if tool is None:
        return {"ok": False, "error": "unknown_tool"}
    try:
        arguments = json.loads(arguments_json)
        return {"ok": True, "data": tool(**arguments)}
    except json.JSONDecodeError:
        return {"ok": False, "error": "invalid_json"}
    except TypeError:
        return {"ok": False, "error": "invalid_arguments"}
    except Exception:
        return {"ok": False, "error": "tool_failure"}

The dispatcher accepts functions from its registry only. Errors become short, stable codes, while detailed stacks stay in protected logs. The model can see invalid_arguments and repair a call without receiving internal server exceptions.

Implement the complete agent loop

PYTHON
import json

def run_agent(client, model: str, question: str, tools_schema: list,
              tools: dict[str, Tool], max_turns: int = 6) -> dict:
    inputs: list = [{"role": "user", "content": question}]
    events: list[dict] = []

    for turn in range(1, max_turns + 1):
        response = client.responses.create(
            model=model,
            tools=tools_schema,
            input=inputs,
        )
        inputs += response.output
        calls = [item for item in response.output if item.type == "function_call"]
        events.append({"turn": turn, "tool_calls": len(calls)})

        if not calls:
            return {
                "status": "succeeded",
                "output": response.output_text,
                "events": events,
            }

        for call in calls:
            result = dispatch_tool(call.name, call.arguments, tools)
            events.append({"tool": call.name, "ok": result["ok"]})
            inputs.append({
                "type": "function_call_output",
                "call_id": call.call_id,
                "output": json.dumps(result),
            })

    return {"status": "budget_exhausted", "events": events}

The loop checks its turn count before each model call. If turn six produces a tool request, that tool still runs, and the absence of a seventh turn then produces budget exhaustion. A production version can block execution earlier when a tool-call limit is reached; this implementation keeps the control flow visible.

Test three exits separately

The success exit occurs when the model returns final text without tool requests. The application stores the text and events with status succeeded.

The failure exit handles unrecoverable application errors such as an invalid task, authorization denial, or corrupt state. An ordinary tool failure does not always end the loop; the model may choose another action after observing the error.

The budget exit prevents endless search or repeated calls. On exhaustion, the application retains existing events and returns budget_exhausted; it never presents partial work as a successful answer.

TEXT
running ──final text──────▶ succeeded
   │
   ├──unrecoverable error─▶ failed
   │
   └──turn limit──────────▶ budget_exhausted

Debug the loop with a scripted model

Live models are stochastic. For now, manually construct three responses—an initial search, a refined search, and the final answer. Chapter 5 will package this script as a fake model and offline tests. The event order should be:

TEXT
turn=1 → search_local_sources(query="agent loops")        → ok
turn=2 → search_local_sources(query="stopping conditions") → ok
turn=3 → final                                                → succeeded

Then construct a script that requests search for all six turns and confirm budget_exhausted. If the test process hangs, an implicit path remains outside the budget.

The first agent milestone

The system now satisfies the minimum definition of an agent: the model chooses tools from observations, tool results enter the next turn, and application code ends execution through explicit conditions. The code still cannot state whether output satisfies the business requirement, and testing remains unstable without API access.

Chapter 5 pauses feature work and adds input constraints, a fake model, and acceptance tests. RAG, memory, and framework migration will all use this comparable baseline.

REFERENCES

References

  1. 01Function Calling | OpenAI API
  2. 02Running Agents | OpenAI API
  3. 03Building Effective AI Agents | Anthropic

Series

AI AGENT Beginner's Guide

Next step

Continue with related topics

Continue along the same topic.

Browse latest news