Building AI Agents with Function Calling: Tools, Structured Output & Agent Loops
Master function calling for AI agents — from basic tool use to complex multi-step agents with planning, memory, and error recovery.
Introduction
Function calling transforms LLMs from text generators into agents that act. This guide covers production patterns for tool use, structured output, agent loops, and orchestration.
Why Function Calling?
| Without Tools | With Function Calling |
|---|---|
| Text-only responses | Actions in the real world |
| Hallucinated facts | Verified data from APIs |
| No memory | Persistent state |
| Single turn | Multi-step workflows |
| Manual integration | Standardized interfaces |
Part 1: Function Calling Fundamentals
OpenAI Function Calling API
# function_calling/basics.py from openai import OpenAI from pydantic import BaseModel, Field from typing import List, Optional import json client = OpenAI() # Define function schema using Pydantic class GetWeather(BaseModel): """Get current weather for a location.""" location: str = Field(description="City and state, e.g. 'San Francisco, CA'") unit: str = Field(default="celsius", enum=["celsius", "fahrenheit"]) class SearchWeb(BaseModel): """Search the web for information.""" query: str = Field(description="Search query") max_results: int = Field(default=5, ge=1, le=10) recency_days: Optional[int] = Field(default=None, ge=1, le=365) # Convert to OpenAI format def pydantic_to_openai(model: type[BaseModel]) -> dict: schema = model.model_json_schema() return { "type": "function", "function": { "name": schema["title"], "description": schema["description"], "parameters": { "type": "object", "properties": schema["properties"], "required": schema.get("required", []) } } } tools = [pydantic_to_openai(GetWeather), pydantic_to_openai(SearchWeb)] # Execute function calls def execute_function(name: str, arguments: dict) -> dict: if name == "GetWeather": # Call actual weather API return {"temperature": 22, "condition": "Sunny", "unit": arguments["unit"]} elif name == "SearchWeb": # Call search API return {"results": [{"title": "Result 1", "url": "..."}]} return {"error": f"Unknown function: {name}"} # Chat completion with tools def chat_with_tools(messages: list, tools: list) -> dict: response = client.chat.completions.create( model="gpt-4o", messages=messages, tools=tools, tool_choice="auto", parallel_tool_calls=True ) return response.choices[0].message # Usage messages = [ {"role": "user", "content": "What's the weather in Tokyo and search for 'AI agents'"} ] response_message = chat_with_tools(messages, tools) if response_message.tool_calls: for tool_call in response_message.tool_calls: args = json.loads(tool_call.function.arguments) result = execute_function(tool_call.function.name, args) messages.append(response_message.model_dump()) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result) }) # Get final response final = client.chat.completions.create( model="gpt-4o", messages=messages, tools=tools ) print(final.choices[0].message.content) Structured Output with JSON Schema
# function_calling/structured_output.py from pydantic import BaseModel, Field, validator from typing import Literal, List, Optional from openai import OpenAI import instructor client = instructor.from_openai(OpenAI()) class AgentAction(BaseModel): """Single action in an agent plan.""" tool: str = Field(description="Tool name to call") args: dict = Field(description="Arguments for the tool") reasoning: str = Field(description="Why this action") class AgentPlan(BaseModel): """Complete plan for solving a task.""" goal: str = Field(description="High-level goal") steps: List[AgentAction] = Field(min_items=1) estimated_duration_seconds: int = Field(ge=1) requires_user_input: bool = False class AgentResult(BaseModel): """Result of agent execution.""" success: bool output: str artifacts: List[str] = Field(default_factory=list) error: Optional[str] = None tokens_used: int = 0 # Use instructor for guaranteed structured output plan = client.chat.completions.create( model="gpt-4o", response_model=AgentPlan, messages=[ {"role": "system", "content": "You are a planning agent. Create detailed plans."}, {"role": "user", "content": "Research the latest LangGraph features and write a summary"} ] ) print(plan.model_dump_json(indent=2)) Part 2: Building a Tool-Enabled Agent
Tool Registry
# agent/tool_registry.py from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Any, Callable, Dict, List, Optional from pydantic import BaseModel import inspect @dataclass class ToolSpec: name: str description: str parameters: dict # JSON Schema function: Callable return_type: type class ToolRegistry: def __init__(self): self.tools: Dict[str, ToolSpec] = {} def register(self, spec: ToolSpec): self.tools[spec.name] = spec def register_function( self, name: str, description: str, func: Callable, param_model: type[BaseModel] = None ): if param_model: schema = param_model.model_json_schema() parameters = { "type": "object", "properties": schema.get("properties", {}), "required": schema.get("required", []) } else: # Infer from function signature sig = inspect.signature(func) parameters = {"type": "object", "properties": {}, "required": []} for param_name, param in sig.parameters.items(): if param_name in ("self", "cls"): continue parameters["properties"][param_name] = { "type": self._python_type_to_json(param.annotation) } if param.default == inspect.Parameter.empty: parameters["required"].append(param_name) self.register(ToolSpec( name=name, description=description, parameters=parameters, function=func, return_type=inspect.signature(func).return_annotation )) def _python_type_to_json(self, annotation) -> str: type_map = { str: "string", int: "integer", float: "number", bool: "boolean", list: "array", dict: "object" } return type_map.get(annotation, "string") def get_openai_tools(self) -> List[dict]: return [ { "type": "function", "function": { "name": spec.name, "description": spec.description, "parameters": spec.parameters } } for spec in self.tools.values() ] async def execute(self, name: str, arguments: dict) -> Any: if name not in self.tools: raise ValueError(f"Unknown tool: {name}") spec = self.tools[name] return await spec.function(**arguments) # Example tools async def web_search(query: str, max_results: int = 5) -> dict: # Implementation return {"results": []} async def code_execute(code: str, language: str = "python") -> dict: # Implementation with sandbox return {"output": "", "error": None} async def file_read(path: str) -> dict: # Implementation return {"content": ""} async def file_write(path: str, content: str) -> dict: # Implementation return {"success": True} registry = ToolRegistry() registry.register_function("web_search", "Search the web", web_search) registry.register_function("code_execute", "Execute code", code_execute) registry.register_function("file_read", "Read a file", file_read) registry.register_function("file_write", "Write a file", file_write) Agent Core Loop
# agent/core.py from dataclasses import dataclass, field from typing import List, Optional, Dict, Any from openai import OpenAI import json import asyncio @dataclass class Message: role: str content: str tool_calls: List[dict] = field(default_factory=list) tool_call_id: str = "" @dataclass class AgentState: messages: List[Message] tools: ToolRegistry max_iterations: int = 10 current_iteration: int = 0 context: Dict[str, Any] = field(default_factory=dict) class ToolAgent: def __init__( self, model: str = "gpt-4o", system_prompt: str = "", tools: ToolRegistry = None, max_iterations: int = 10, temperature: float = 0 ): self.client = OpenAI() self.model = model self.system_prompt = system_prompt self.tools = tools or ToolRegistry() self.max_iterations = max_iterations self.temperature = temperature async def run(self, user_input: str, context: dict = None) -> AgentState: state = AgentState( messages=[ Message(role="system", content=self.system_prompt), Message(role="user", content=user_input) ], tools=self.tools, max_iterations=self.max_iterations, context=context or {} ) while state.current_iteration < state.max_iterations: state.current_iteration += 1 # Get model response response = await self._get_response(state) if not response.tool_calls: # No tool calls = done state.messages.append(Message( role="assistant", content=response.content )) break # Execute tool calls state.messages.append(Message( role="assistant", content=response.content or "", tool_calls=response.tool_calls )) for tool_call in response.tool_calls: result = await self._execute_tool(tool_call) state.messages.append(Message( role="tool", Source:https://dev.to/ipsphil_digital/building-ai-agents-with-function-calling-tools-structured-output-agent-loops-26njPreviousCreate reproducible API support tickets without sharing credentialsNextSolana Token Metadata Explained: Name, Symbol, Logo, and More
Related posts

Always-on Apple Watch display showing the wrong time? Here’s the fix
09to5Mac

Affinity for iPad all-in-one design app now available, free for individuals
09to5Mac

Affinity’s creative studio is now available on iPad for free
0The Verge

SpaceX could buy Boost Mobile as part of its big mobile carrier plans
0The Verge
Northwood Space raises $375M Series C at a $2B valuation from a16z and DFJ growth
0TechCrunch

The Download: the agentic web arrives and the drive for safer roads
0MIT Technology Review