AI Agents
An AI agent is an LLM that can reason about a task, decide which tools to call, observe the results, and iterate until the task is complete. Unlike a simple prompt-response interaction, agents operate in a loop - they think, act, observe, and repeat.
Most production agent failures between 2024 and 2026 were architectural, not model quality failures. The model is capable; the surrounding system (tool design, error handling, memory, guardrails) determines whether it works reliably.
How Agents Work
The ReAct Loop
Every agent follows the same fundamental cycle: Reason about what to do next, Act by calling a tool, Observe the result, then decide whether to continue or return a final answer.
Agent Architecture
The Agentic Reasoning Loop
The ReAct loop above compresses the cycle into three words: Reason, Act, Observe. In practice, "Reason" hides several distinct decisions the agent has to make before it ever touches a tool, and the loop has explicit entry and exit conditions. The walkthrough below breaks the cycle into the granularity needed for implementation planning - what an agentic system actually does, step by step, between receiving a task and returning an answer.
- User provides a task. The user gives the agent a task, not necessarily a question. Unlike a plain LLM call, the agent can decide for itself whether it needs external information before it responds.
- Agent understands the request. The agent converts the natural-language request into something it can reason about, analysing the user's goal, what information is required, and which tools are available to it.
- Planning. Before taking any action, the agent produces a plan, breaking a complex task into smaller sub-tasks it can execute one at a time.
- Tool selection. The agent decides whether a tool is required at all. If the task can be answered from reasoning alone, no tool call happens. If it needs external information or an action, the agent picks the most appropriate tool - a "search the internet" request needs a web search tool, not a database lookup.
- Tool calling. The agent invokes the selected tool, passing input in the format the tool expects. Example: tool
web_search, input"latest stock price change". - Tool execution. The tool performs the requested operation - a query, an API call, a lookup - and returns its output to the agent.
- Observation. The agent examines what the tool returned: the data itself, and whether the call succeeded or failed. This is the "observation" phase of the loop.
- Iterative reasoning loop. If the task isn't complete, the agent starts another cycle: Think -> Act -> Observe -> Evaluate -> Think Again. This repeats until the objective is met, the agent reaches a stopping condition, or it hits the maximum iteration limit (see Guardrails under Production Considerations below).
- Response construction and output. Once the agent has gathered enough information, it assembles the final response - combining tool outputs, dropping anything irrelevant, and organising the rest into a text answer, a report, or a dashboard, depending on what was asked.
This is the same Reason-Act-Observe cycle shown above, laid out at the granularity you need to design prompts, logging, and guardrails around it:
Two failure modes show up repeatedly at Tool Selection and Tool Calling (steps 4 and 5): giving the agent too many tools to choose between, and leaving the rules for when to use a tool ambiguous. The first is expanded on below under Tool design principles ("fewer tools, better results"); the second means the agent's instructions should state explicitly when reasoning alone is sufficient and when a tool call is required.
Tool Use and Function Calling
Tools are functions the agent can invoke. The LLM decides which tool to call, with what arguments, based on the user's request and the tool descriptions you provide.
Defining tools
tools = [
{
"type": "function",
"function": {
"name": "get_order_status",
"description": "Look up the current status of a customer order by order ID",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "The order ID (e.g. ORD-12345)"
}
},
"required": ["order_id"]
}
}
},
{
"type": "function",
"function": {
"name": "search_knowledge_base",
"description": "Search internal documentation for answers to policy or product questions",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"}
},
"required": ["query"]
}
}
}
]
Tool calling flow
from openai import AzureOpenAI
import json
client = AzureOpenAI(...)
messages = [
{"role": "system", "content": "You are a support agent. Use tools to answer questions."},
{"role": "user", "content": "Where is my order ORD-98765?"}
]
# Step 1: LLM decides to call a tool
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools
)
tool_call = response.choices[0].message.tool_calls[0]
# tool_call.function.name = "get_order_status"
# tool_call.function.arguments = '{"order_id": "ORD-98765"}'
# Step 2: Execute the tool
result = get_order_status(order_id="ORD-98765")
# Step 3: Feed result back to LLM
messages.append(response.choices[0].message)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result)
})
# Step 4: LLM generates final answer using tool result
final = client.chat.completions.create(model="gpt-4o", messages=messages)
print(final.choices[0].message.content)
Tool design principles
- Descriptions matter more than names. The LLM reads the description to decide when to call a tool. Vague descriptions cause wrong tool selection.
- Fewer tools, better results. 5-8 well-designed tools outperform 30 poorly-scoped ones. The model gets confused with too many options.
- Return structured data. Tools should return JSON, not prose. Let the LLM format the response for the user.
- Handle errors explicitly. Return error messages the LLM can reason about, not stack traces.
- Idempotent where possible. Agents retry. If a tool creates a record, it should check if it already exists.
Agent Design Patterns
| Pattern | How it works | Best for |
|---|---|---|
| ReAct | Reason-Act-Observe loop with tools | General-purpose agents, most use cases |
| Plan-then-Execute | Generate full plan first, then execute steps | Complex multi-step tasks with dependencies |
| Reflection | Agent critiques its own output, then revises | Writing, code generation, quality-sensitive tasks |
| Tool-only | No reasoning loop, just route to correct tool | Simple classification + action (chatbots, routing) |
ReAct with LangGraph
from langgraph.prebuilt import create_react_agent
from langchain_openai import AzureChatOpenAI
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get current weather for a city."""
# Implementation here
return f"22C, sunny in {city}"
@tool
def search_flights(origin: str, destination: str, date: str) -> str:
"""Search available flights between two cities on a date."""
# Implementation here
return f"3 flights found from {origin} to {destination} on {date}"
llm = AzureChatOpenAI(model="gpt-4o", temperature=0)
agent = create_react_agent(llm, tools=[get_weather, search_flights])
result = agent.invoke({
"messages": [{"role": "user", "content": "I want to fly from London to Barcelona next Friday. What's the weather like there?"}]
})
Memory Systems
Agents need memory to maintain context across interactions and learn from past actions.
| Memory type | What it stores | Persistence | Use case |
|---|---|---|---|
| Conversation | Current chat history | Session | Multi-turn dialogue |
| Working | Current task state, intermediate results | Task | Complex multi-step tasks |
| Short-term | Recent interactions summary | Hours/days | Personalisation within a session |
| Long-term | User preferences, facts, past decisions | Permanent | Cross-session personalisation |
Production Considerations
Failure modes
| Failure | Cause | Mitigation |
|---|---|---|
| Infinite loops | Agent keeps calling tools without converging | Set max iterations (typically 5-10) |
| Cascading tool errors | First tool returns bad data, subsequent calls fail | Validate tool outputs, implement retry with backoff |
| Wrong tool selection | Ambiguous tool descriptions | Improve descriptions, reduce tool count, add examples |
| Hallucinated tool calls | Model invents tool names or arguments | Strict schema validation, reject unknown tools |
| Cost runaway | Agent loops burn tokens | Token budget per task, circuit breaker on spend |
Guardrails
MAX_ITERATIONS = 8
MAX_TOKENS_PER_TASK = 50000
ALLOWED_TOOLS = {"get_order_status", "search_knowledge_base", "create_ticket"}
def run_agent_with_guardrails(agent, user_input):
iterations = 0
total_tokens = 0
while iterations < MAX_ITERATIONS:
response = agent.step(user_input)
total_tokens += response.usage.total_tokens
if total_tokens > MAX_TOKENS_PER_TASK:
return "Task exceeded token budget. Escalating to human."
if response.tool_calls:
for call in response.tool_calls:
if call.function.name not in ALLOWED_TOOLS:
return f"Blocked: agent tried to call unauthorized tool {call.function.name}"
if response.is_final:
return response.content
iterations += 1
return "Agent did not converge. Escalating to human."
Observability
Every production agent needs tracing. You must be able to see:
- What the agent reasoned at each step
- Which tools it called and with what arguments
- What each tool returned
- How many iterations it took
- Total token consumption and latency
Tools: LangSmith, Langfuse, Phoenix (Arize), Azure AI Foundry Tracing, OpenTelemetry.
Framework Comparison
| Framework | Strengths | Best for |
|---|---|---|
| LangGraph | Graph-based control flow, checkpointing, human-in-the-loop | Complex agents with branching logic |
| Azure Foundry Agents | Managed runtime, built-in tracing, Microsoft integration | Enterprise on Azure |
| OpenAI Assistants | Simple API, built-in file search and code interpreter | Quick prototypes, OpenAI-only |
| AWS Bedrock Agents | AWS integration, knowledge bases, guardrails | Enterprise on AWS |
| Semantic Kernel | .NET/Python, planner, plugin system | Microsoft ecosystem developers |
Key Takeaways
- Agents are LLMs in a loop. The loop (ReAct) is simple; the hard part is tool design, error handling, and guardrails.
- Tool descriptions determine agent quality more than model choice. Write them like API documentation, not casual comments.
- Set hard limits: max iterations, token budgets, allowed tool lists. Agents without guardrails will surprise you in production.
- Trace everything. You cannot debug an agent from its final output alone.
- Start with a single ReAct agent and 3-5 tools. Add complexity only when the simple version demonstrably fails.