Building AI Agents from Scratch
Tool Calling from Scratch
Parse tool schemas and arguments, execute your code, and return results to the model.
A tool call is JSON the model invented. Treat it as untrusted input. Parse arguments, run a function you wrote, append a tool message, then think again.
Registry and dispatch
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parent
def list_dir(path: str) -> dict:
target = (ROOT / path).resolve()
if ROOT not in target.parents and target != ROOT:
return {"ok": False, "error": "path escapes the project"}
if not target.is_dir():
return {"ok": False, "error": "not a directory"}
names = sorted(p.name for p in target.iterdir())[:50]
return {"ok": True, "path": str(target), "names": names}
REGISTRY = {"list_dir": list_dir}
def run_tool(name: str, arguments_json: str) -> str:
fn = REGISTRY.get(name)
if fn is None:
return json.dumps({"ok": False, "error": f"unknown tool {name}"})
try:
args = json.loads(arguments_json or "{}")
except json.JSONDecodeError:
return json.dumps({"ok": False, "error": "arguments are not JSON"})
result = fn(**args) if isinstance(args, dict) else fn()
return json.dumps(result)Close the loop
After think, if tool_calls is set, append the assistant message with those calls, then one tool message per call. Then loop. Do not drop the assistant tool-call message — the API requires it.
def run(user_text: str, max_steps: int = 8) -> str:
messages = [
{"role": "system", "content": "Use list_dir when you need filenames. Then answer briefly."},
{"role": "user", "content": user_text},
]
for _ in range(max_steps):
message = think(messages, tools=TOOLS)
calls = message.tool_calls or []
if not calls:
return message.content or ""
messages.append(message)
for call in calls:
output = run_tool(call.function.name, call.function.arguments)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": output,
})
return "stopped: max steps"think is the same create call with tools=TOOLS. Some clients let you messages.append(message) with the SDK object; others want a dict. If a host rejects the object, convert it to {"role": "assistant", "content": ..., "tool_calls": ...}.
Check it
Ask “What Python files are in this folder?” You should see a JSON list in the second model call’s context, then a sentence that names files. If path is ../, the tool must refuse. Next: add a second tool and watch selection.