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.
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.
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.
┌────────┐ ① 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
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.
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_02If 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_callfinds the allowlisted function and returns the samecall_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.
┌──────────────────────┐
│ Call model, read output│
└──────────┬───────────┘
▼
┌──────────────────┐
│ function_call? │
└──────┬─────┬─────┘
yes no
│ │
▼ ▼
Run allowlisted Return final text
tool
│
▼
Append tool result
│
└──────▶ Next turnKeep 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
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
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.
running ──final text──────▶ succeeded
│
├──unrecoverable error─▶ failed
│
└──turn limit──────────▶ budget_exhaustedDebug 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:
turn=1 → search_local_sources(query="agent loops") → ok
turn=2 → search_local_sources(query="stopping conditions") → ok
turn=3 → final → succeededThen 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
Series
AI AGENT Beginner's Guide