Build a Multi-Step Agent
Implement an agent that can plan, execute, and iterate on multi-step tasks with tool use and state management.
1. Understand the Scenario
You're building a research agent that can answer complex questions by searching the web, reading documents, and synthesizing information across multiple steps.
Learning Objectives
- Understand the plan-execute-observe loop
- Implement state management across steps
- Handle tool failures gracefully
- Know when to stop (termination conditions)
Concepts You'll Practice
2. Follow the Instructions
What Makes an Agent?
An agent is more than a chatbot with tools. It's a system that:
- Plans — Decides what steps to take
- Executes — Takes actions using tools
- Observes — Processes results
- Iterates — Adjusts plan based on observations
- Terminates — Knows when it's done
The Agent Loop
┌─────────────────────────────────────────┐
│ │
│ User Query │
│ │ │
│ ▼ │
│ ┌───────────┐ │
│ │ PLAN │ ◄─────────────┐ │
│ └─────┬─────┘ │ │
│ │ │ │
│ ▼ │ │
│ ┌───────────┐ │ │
│ │ EXECUTE │ (use tools) │ │
│ └─────┬─────┘ │ │
│ │ │ │
│ ▼ │ │
│ ┌───────────┐ │ │
│ │ OBSERVE │ (process) │ │
│ └─────┬─────┘ │ │
│ │ │ │
│ ▼ │ │
│ ┌───────────┐ No │ │
│ │ DONE? │ ──────────────┘ │
│ └─────┬─────┘ │
│ │ Yes │
│ ▼ │
│ Final Answer │
│ │
└─────────────────────────────────────────┘
Step 1: Define Agent State
The agent needs to track its progress across iterations.
interface AgentState {
query: string; // Original user query
plan: string[]; // Current plan steps
completed_steps: string[]; // What we've done
observations: Observation[]; // Results from tools
current_step: number; // Where we are in the plan
max_iterations: number; // Safety limit
iteration: number; // Current iteration
status: 'planning' | 'executing' | 'complete' | 'failed';
}
interface Observation {
step: string;
tool_used: string;
result: any;
success: boolean;
}
Step 2: Define Tools
Our research agent has three tools: search, read, and synthesize.
const tools: OpenAI.ChatCompletionTool[] = [
{
type: 'function',
function: {
name: 'web_search',
description: 'Search the web for information',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' }
},
required: ['query']
}
}
},
{
type: 'function',
function: {
name: 'read_url',
description: 'Read and extract content from a URL',
parameters: {
type: 'object',
properties: {
url: { type: 'string', description: 'URL to read' }
},
required: ['url']
}
}
},
{
type: 'function',
function: {
name: 'finish',
description: 'Complete the task and provide final answer',
parameters: {
type: 'object',
properties: {
answer: { type: 'string', description: 'Final synthesized answer' },
sources: {
type: 'array',
items: { type: 'string' },
description: 'URLs used'
}
},
required: ['answer', 'sources']
}
}
}
];
💡 TIP: The 'finish' Tool
Give the agent an explicit way to signal completion. This is clearer than detecting when it has no more tool calls.
Step 3: The Agent Loop
This is the core execution loop. It continues until done or max iterations.
async function runAgent(query: string): Promise<string> {
const state: AgentState = {
query,
plan: [],
completed_steps: [],
observations: [],
current_step: 0,
max_iterations: 10,
iteration: 0,
status: 'planning'
};
while (state.status !== 'complete' && state.iteration < state.max_iterations) {
state.iteration++;
// Build context from state
const messages = buildMessages(state);
// Call the model
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages,
tools
});
const assistantMessage = response.choices[0].message;
// Handle tool calls or completion
if (assistantMessage.tool_calls) {
for (const call of assistantMessage.tool_calls) {
const result = await executeTool(call);
if (call.function.name === 'finish') {
state.status = 'complete';
return result.answer;
}
state.observations.push({
step: call.function.name,
tool_used: call.function.name,
result,
success: !result.error
});
}
}
}
return 'Agent reached max iterations without completing.';
}
⚠️ WARNING: Always Set Max Iterations
Agents can get stuck in loops. Always set a maximum iteration limit and handle the case when it's exceeded.
Your Task
Complete the agent implementation with:
- State management
- Tool execution
- Message building that includes observations
- Proper termination handling
3. Try It Yourself
import OpenAI from 'openai';
const openai = new OpenAI();
// Agent state
interface AgentState {
query: string;
observations: { tool: string; result: any }[];
iteration: number;
max_iterations: number;
status: 'running' | 'complete' | 'failed';
}
// Mock tool implementations
async function webSearch(query: string): Promise<{ results: { title: string; url: string; snippet: string }[] }> {
// Simulate search results
return {
results: [
{ title: 'Example Result 1', url: 'https://example.com/1', snippet: 'Some information...' },
{ title: 'Example Result 2', url: 'https://example.com/2', snippet: 'More information...' }
]
};
}
async function readUrl(url: string): Promise<{ content: string; title: string }> {
// Simulate reading a URL
return {
title: 'Page Title',
content: 'This is the extracted content from the page...'
};
}
// TODO: Define tools array
const tools: OpenAI.ChatCompletionTool[] = [];
// TODO: Execute a tool call and return the result
async function executeTool(call: OpenAI.ChatCompletionMessageToolCall): Promise<any> {
throw new Error('Not implemented');
}
// TODO: Build messages array including observations
function buildMessages(state: AgentState): OpenAI.ChatCompletionMessageParam[] {
throw new Error('Not implemented');
}
// TODO: Main agent loop
async function runAgent(query: string): Promise<{
answer: string;
sources: string[];
iterations: number;
}> {
throw new Error('Not implemented');
}
// Test the agent
const testQueries = [
"What are the latest developments in quantum computing?",
"Compare React and Vue.js for building web applications"
];
for (const query of testQueries) {
console.log(`\nQuery: ${query}`);
runAgent(query).then(result => {
console.log(`Answer: ${result.answer}`);
console.log(`Sources: ${result.sources.join(', ')}`);
console.log(`Iterations: ${result.iterations}`);
});
} This typescript exercise requires local setup. Copy the code to your IDE to run.
4. Get Help (If Needed)
Reveal progressive hints
5. Check the Solution
Reveal the complete solution
/**
* Key Points:
* - Line ~6: State tracks everything the agent needs across iterations
* - Line ~77: The 'finish' tool lets the agent explicitly signal completion
* - Line ~117: Include previous observations so the model can build on them
* - Line ~152: Always check iteration limit to prevent infinite loops
* - Line ~184: Track sources as we go for proper citation
*/
import OpenAI from 'openai';
const openai = new OpenAI();
interface AgentState {
query: string;
observations: { tool: string; args: any; result: any; success: boolean }[];
iteration: number;
max_iterations: number;
status: 'running' | 'complete' | 'failed';
final_answer?: string;
sources: string[];
}
// Mock tool implementations
async function webSearch(query: string): Promise<{
results: { title: string; url: string; snippet: string }[]
}> {
console.log(` 🔍 Searching: ${query}`);
return {
results: [
{
title: 'Quantum Computing Advances 2024',
url: 'https://example.com/quantum-2024',
snippet: 'Major breakthroughs in error correction...'
},
{
title: 'IBM Quantum Roadmap',
url: 'https://ibm.com/quantum',
snippet: '1000+ qubit processors by 2025...'
}
]
};
}
async function readUrl(url: string): Promise<{ content: string; title: string }> {
console.log(` 📖 Reading: ${url}`);
return {
title: 'Detailed Article',
content: `Content from ${url}: Quantum computing has made significant progress in 2024, with error correction improvements and larger qubit counts...`
};
}
const tools: OpenAI.ChatCompletionTool[] = [
{
type: 'function',
function: {
name: 'web_search',
description: 'Search the web for information on a topic',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' }
},
required: ['query']
}
}
},
{
type: 'function',
function: {
name: 'read_url',
description: 'Read and extract content from a URL',
parameters: {
type: 'object',
properties: {
url: { type: 'string', description: 'URL to read' }
},
required: ['url']
}
}
},
{
type: 'function',
function: {
name: 'finish',
description: 'Complete the research and provide final answer',
parameters: {
type: 'object',
properties: {
answer: { type: 'string', description: 'Synthesized answer' },
sources: {
type: 'array',
items: { type: 'string' },
description: 'URLs used as sources'
}
},
required: ['answer', 'sources']
}
}
}
];
async function executeTool(
call: OpenAI.ChatCompletionMessageToolCall
): Promise<any> {
// Type guard for function calls
if (call.type !== 'function') {
return { error: 'Not a function call' };
}
const args = JSON.parse(call.function.arguments);
try {
switch (call.function.name) {
case 'web_search':
return await webSearch(args.query);
case 'read_url':
return await readUrl(args.url);
case 'finish':
return { answer: args.answer, sources: args.sources };
default:
return { error: `Unknown tool: ${call.function.name}` };
}
} catch (error) {
return { error: String(error) };
}
}
function buildMessages(state: AgentState): OpenAI.ChatCompletionMessageParam[] {
const messages: OpenAI.ChatCompletionMessageParam[] = [
{
role: 'system',
content: `You are a research agent. Your task is to answer questions by:
1. Searching the web for relevant information
2. Reading promising URLs for details
3. Synthesizing findings into a comprehensive answer
Use web_search to find sources, read_url to get details, and finish when ready.
Always cite your sources. Be thorough but efficient.`
},
{
role: 'user',
content: state.query
}
];
// Add observations from previous iterations
if (state.observations.length > 0) {
const observationSummary = state.observations
.map((obs, i) => {
const status = obs.success ? '✓' : '✗';
return `[${i + 1}] ${status} ${obs.tool}(${JSON.stringify(obs.args)})\nResult: ${JSON.stringify(obs.result, null, 2)}`;
})
.join('\n\n');
messages.push({
role: 'assistant',
content: `Previous observations:\n${observationSummary}\n\nContinuing research...`
});
}
return messages;
}
async function runAgent(query: string): Promise<{
answer: string;
sources: string[];
iterations: number;
}> {
const state: AgentState = {
query,
observations: [],
iteration: 0,
max_iterations: 10,
status: 'running',
sources: []
};
console.log(`\n🤖 Starting agent for: "${query}"`);
while (state.status === 'running' && state.iteration < state.max_iterations) {
state.iteration++;
console.log(`\n--- Iteration ${state.iteration} ---`);
const messages = buildMessages(state);
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages,
tools
});
const assistantMessage = response.choices[0].message;
if (!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) {
// No tool calls - shouldn't happen with our setup, but handle it
console.log(' ⚠️ No tool calls, prompting to continue or finish');
continue;
}
for (const call of assistantMessage.tool_calls) {
// Skip non-function calls
if (call.type !== 'function') continue;
const args = JSON.parse(call.function.arguments);
const result = await executeTool(call);
if (call.function.name === 'finish') {
state.status = 'complete';
state.final_answer = result.answer;
state.sources = result.sources;
console.log(' ✅ Agent finished');
break;
}
state.observations.push({
tool: call.function.name,
args,
result,
success: !result.error
});
// Track sources from search results
if (call.function.name === 'web_search' && result.results) {
for (const r of result.results) {
if (!state.sources.includes(r.url)) {
state.sources.push(r.url);
}
}
}
}
}
if (state.status !== 'complete') {
state.status = 'failed';
return {
answer: 'Agent could not complete the task within iteration limit.',
sources: state.sources,
iterations: state.iteration
};
}
return {
answer: state.final_answer || '',
sources: state.sources,
iterations: state.iteration
};
}
// Test
runAgent('What are the latest developments in quantum computing?').then(result => {
console.log('\n=== Final Result ===');
console.log(`Answer: ${result.answer}`);
console.log(`Sources: ${result.sources.join(', ')}`);
console.log(`Iterations: ${result.iterations}`);
});
/* Expected output:
🤖 Starting agent for: "What are the latest developments in quantum computing?"
--- Iteration 1 ---
🔍 Searching: quantum computing 2024 developments
--- Iteration 2 ---
📖 Reading: https://example.com/quantum-2024
--- Iteration 3 ---
✅ Agent finished
=== Final Result ===
Answer: Recent developments in quantum computing include significant advances in error correction...
Sources: https://example.com/quantum-2024, https://ibm.com/quantum
Iterations: 3
*/ Common Mistakes
Not including previous observations in messages
Why it's wrong: The model has no memory between API calls. Without observations, it will repeat the same actions.
How to fix: Add observations to the message history so the model knows what it has already learned.
No iteration limit
Why it's wrong: Agents can get stuck in loops, wasting API calls and never completing.
How to fix: Always set max_iterations and handle the case when it's exceeded.
No explicit 'finish' tool
Why it's wrong: Detecting completion by absence of tool calls is unreliable. The model might stop for other reasons.
How to fix: Add a 'finish' tool that the agent calls when done, with the final answer as a parameter.
Not tracking state across iterations
Why it's wrong: Without state, you can't know what the agent has done or provide that context to the model.
How to fix: Use an AgentState object that persists across the loop iterations.
Test Cases
Uses multiple tools
Agent should search and then read at least one URL
Research quantum computingUses web_search, then read_url, then finishRespects iteration limit
Agent should not exceed max_iterations
Research an obscure topicTerminates at or before max_iterationsProvides sources
Final answer should include sources
Any research querysources array is non-emptyHandles tool failures
Agent should continue if a tool fails
Query that causes tool errorAgent continues or gracefully fails