Bringing It All Together
You’ve learned tools, ReAct, multi-agent systems, memory, context management, planning, LangGraph, error handling, and observability. This capstone is where those pieces combine into one complete system.
We’re building a Research Agent that:
- Accepts a research topic from the user
- Searches the web for relevant information across multiple queries
- Synthesises the findings into a structured report
- Formats the report as a professional email
- Sends it to the user
The system includes budget controls, retry logic, structured logging, and output validation, everything you’d expect in a production deployment.
Project Architecture
User provides: topic + recipient email
↓
[Budget Check], is this request within scope?
↓
[Research Agent], multiple search queries + URL reading
↓
[Validation], is the research complete and well-formed?
↓
[Synthesis Agent], turns research notes into a structured report
↓
[Email Formatter], converts report to HTML email
↓
[Email Sender], sends via SendGrid/SMTP
↓
[Cost Summary], logs total tokens and USD spent
Each stage is a separate function with clear inputs and outputs. This separation makes it easy to test each stage independently and swap implementations (e.g., change the email provider) without touching unrelated code.
Step 1: Tool Definitions
Define all the tools the research agent can use:
from openai import OpenAI
import requests, json, time, smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
client = OpenAI()
RESEARCH_TOOLS = [
{
"type": "function",
"function": {
"name": "search_web",
"description": "Search the web for current information. Use specific, targeted queries.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "The search query"},
"num_results": {"type": "integer", "description": "Results to return (3-8)", "default": 5}
},
"required": ["query"]
}
}
},
{
"type": "function",
"function": {
"name": "read_webpage",
"description": "Read the full text content of a URL. Use to get details from a specific page.",
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "The URL to read"},
"max_chars": {"type": "integer", "description": "Max chars to return", "default": 3000}
},
"required": ["url"]
}
}
}
]
Step 2: Tool Implementations
def search_web(query: str, num_results: int = 5) -> list[dict]:
"""Search using the Serper API."""
response = requests.post(
'https://google.serper.dev/search',
headers={'X-API-KEY': 'YOUR_SERPER_KEY'},
json={'q': query, 'num': num_results},
timeout=10
)
results = response.json().get('organic', [])
return [{'title': r['title'], 'url': r['link'], 'snippet': r['snippet']}
for r in results[:num_results]]
def read_webpage(url: str, max_chars: int = 3000) -> str:
"""Fetch and extract text from a URL."""
from bs4 import BeautifulSoup
response = requests.get(url, timeout=10, headers={'User-Agent': 'ResearchBot/1.0'})
soup = BeautifulSoup(response.text, 'html.parser')
# Remove navigation, ads, scripts
for tag in soup(['script', 'style', 'nav', 'footer', 'header']):
tag.decompose()
text = soup.get_text(separator='\n', strip=True)
return text[:max_chars]
TOOL_REGISTRY = {
'search_web': search_web,
'read_webpage': read_webpage,
}
Step 3: The Research Agent
import uuid
RESEARCH_SYSTEM = """
You are a research specialist. Your job is to find accurate, comprehensive information.
Research strategy:
1. Start with 2-3 broad searches to understand the landscape
2. Follow up with specific searches for key facts, numbers, and recent developments
3. Read 1-2 key pages in full for depth
4. Take notes on important findings as you go
When you have covered: overview, key players, recent developments, and future outlook, you're done.
Stop searching and provide a final structured summary.
Output format (at the end):
## OVERVIEW
...
## KEY PLAYERS
...
## RECENT DEVELOPMENTS
...
## FUTURE OUTLOOK
...
## SOURCES
- url1
- url2
"""
def run_research_agent(topic: str, budget_usd: float = 0.75) -> dict:
"""Research a topic and return structured findings."""
run_id = str(uuid.uuid4())[:8]
cost = 0.0
messages = [
{'role': 'system', 'content': RESEARCH_SYSTEM},
{'role': 'user', 'content': f'Research this topic thoroughly: {topic}'},
]
print(f"[{run_id}] Starting research: {topic}")
for step in range(15):
response = client.chat.completions.create(
model='gpt-5.6-sol', messages=messages, tools=RESEARCH_TOOLS,
tool_choice='auto', temperature=0.1,
)
# Track cost
usage = response.usage
step_cost = (usage.prompt_tokens * 2.5 + usage.completion_tokens * 10) / 1_000_000
cost += step_cost
if cost > budget_usd:
return {'error': f'Budget exceeded at step {step}: ${cost:.3f}', 'partial': True}
msg = response.choices[0].message
messages.append(msg)
if not msg.tool_calls:
print(f"[{run_id}] Research complete after {step+1} steps. Cost: ${cost:.4f}")
return {
'content': msg.content,
'topic': topic,
'cost': round(cost, 4),
'steps': step + 1,
'run_id': run_id,
}
# Execute tool calls
for tc in msg.tool_calls:
name = tc.function.name
args = json.loads(tc.function.arguments)
print(f"[{run_id}] → {name}({list(args.keys())})")
try:
result = TOOL_REGISTRY[name](**args)
except Exception as e:
result = f"Error: {e}"
messages.append({
'role': 'tool',
'tool_call_id': tc.id,
'content': json.dumps(result)[:4000], # trim large results
})
return {'error': 'Max steps reached', 'partial': True}
Step 4: Synthesis and Email
Turn the raw research into a polished email:
def synthesise_report(research: dict) -> str:
"""Convert research notes into a clean email-ready report."""
response = client.chat.completions.create(
model='gpt-5.6-sol',
messages=[{
'role': 'user',
'content': f"""Convert this research into a professional email report.
Topic: {research['topic']}
Research findings:
{research['content']}
Write a clear, scannable email with:
- Subject line
- 2-sentence introduction
- 4 key findings as bullet points with specific facts/numbers
- 1-paragraph future outlook
- List of 3-5 sources with URLs
Format: plain text, professional tone."""
}],
temperature=0.3,
)
return response.choices[0].message.content
def send_email_report(to_address: str, report_text: str, topic: str):
"""Send the report via SMTP."""
# Extract subject if present
lines = report_text.strip().split('\n')
subject = f"Research Report: {topic}"
for line in lines[:3]:
if line.lower().startswith('subject:'):
subject = line.split(':', 1)[1].strip()
break
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = '[email protected]'
msg['To'] = to_address
msg.attach(MIMEText(report_text, 'plain'))
# Replace with your SMTP settings
with smtplib.SMTP('smtp.gmail.com', 587) as server:
server.starttls()
server.login('[email protected]', 'your-app-password')
server.send_message(msg)
print(f"Email sent to {to_address}")
Step 5: The Complete Pipeline
Wire everything together into one function:
def research_and_email(topic: str, recipient: str, budget_usd: float = 1.0) -> dict:
"""
Complete pipeline: research a topic and email the report.
Returns a summary of what was done and how much it cost.
"""
print(f"\nResearch pipeline starting...")
print(f"Topic: {topic}")
print(f"Recipient: {recipient}")
print(f"Budget: ${budget_usd:.2f}\n")
# Phase 1: Research
research = run_research_agent(topic, budget_usd=budget_usd * 0.7)
if research.get('error'):
return {'success': False, 'error': research['error']}
# Phase 2: Synthesise
print("Synthesising report...")
report = synthesise_report(research)
# Phase 3: Send
print("Sending email...")
try:
send_email_report(recipient, report, topic)
success = True
except Exception as e:
print(f"Email failed: {e}")
success = False
summary = {
'success': success,
'topic': topic,
'recipient': recipient,
'research_steps': research.get('steps', 0),
'total_cost_usd': research.get('cost', 0),
'report_preview': report[:300] + '...',
}
print(f"\nPipeline complete!")
print(f"Cost: ${summary['total_cost_usd']:.4f}")
print(f"Steps: {summary['research_steps']}")
return summary
# Run it!
result = research_and_email(
topic="The impact of AI coding assistants on software developer productivity in 2024",
recipient="[email protected]",
budget_usd=1.00
)
print(json.dumps(result, indent=2))
Congratulations: You’ve Built a Production Agent
The system you’ve just built contains:
- Multi-tool agent loop with search and webpage reading
- Budget controls that prevent runaway costs
- Retry-capable tools (add
@with_retryfrom module 10) - Cost tracking logged per run
- Multi-stage pipeline with clear separation between research, synthesis, and delivery
- Real deliverable, an actual email in the recipient’s inbox
These are the same patterns used in real production agent systems at companies across every industry. The tools and models will change as technology advances. The patterns, separation of concerns, reliability-first design, observability, human checkpoints, are enduring principles.
Exercise: Run the full pipeline on a topic relevant to your work or industry. Before running, estimate how many search calls it will make and what it will cost. After running, compare your estimate to reality. Then add one improvement: perhaps a validation step that checks whether the research found at least 3 distinct sources before moving to synthesis, retrying if not.